niuzj

给 AI Agent 加状态机,踩了个 instruction 注入的坑

做 Agent 做到一定复杂度,一定会遇到这个问题:多步骤流程怎么编排。

我们的场景是 UGC 视频制作——用户说"帮我做一条唇釉的带货视频",Agent 需要走完意图分析、搜索对标、制定策略、仿写脚本、达人选角、生成素材这一整套流程。每一步都有不同的指令,需要调不同的工具,用户还可能在任何一步说"不满意,重来"。

靠 system prompt 里写一大段流程说明让 LLM 自己记住做到哪了?试过,对话一长就乱。跳步、重复、忘记之前收集的信息,各种问题。

所以我们给 Agent 装了一个状态机。

为什么是状态机

状态机的核心价值是把"Agent 应该做什么"从 LLM 的记忆里抽出来,变成一个确定性的外部数据结构。

不用状态机的时候,Agent 的行为完全依赖 LLM 对上下文的理解。LLM 需要从几十轮对话历史里推断出"我现在在第几步、下一步该做什么、之前收集了哪些信息"。这在 3-5 轮对话里还行,到了 10+ 轮就不可靠了。

用了状态机之后,每一轮对话开始时,系统从外部状态里读出"当前在哪一步",把这一步的 instruction 注入到 LLM 的上下文里。LLM 不需要记忆,它只需要执行当前步骤的指令。

这个思路不是我们发明的。研究了 30 多个开源 Agent 框架后发现,所有做多步骤编排的框架本质上都是这个模式:

                    ┌─────────────────────┐
                    │    状态机 / 工作流    │
                    │  (确定性,外部维护)  │
                    │                     │
                    │  当前步骤 → instruction
                    │  转移条件 → 下一步    │
                    │  共享数据 → state.data │
                    └──────────┬──────────┘
                               │ 注入 instruction
                               ▼
                    ┌─────────────────────┐
                    │     LLM Agent       │
                    │  (概率性,执行层)   │
                    │                     │
                    │  看到 instruction    │
                    │  → 调用工具          │
                    │  → 回复用户          │
                    │  → 推进状态机        │
                    └─────────────────────┘

确定性的编排层告诉 Agent “该做什么”,概率性的 Agent 层决定"怎么做"。两层各管各的。

我们的状态机怎么设计的

借鉴了 Burr 框架的 FSM 模式。核心就三个概念:

Step:一个步骤,带有 instruction(注入给 LLM 的指令)、transitions(出边)、advance_mode(推进方式)。

Transition:一条有向边,带有 condition。condition 用 Burr 的 when() 语义——condition={"approved": True} 表示当 state.data["approved"] == True 时走这条边。condition=None 是无条件默认边。

State:运行时状态,记录当前步骤、已完成步骤、跨步骤共享数据。存在 session 里,每轮对话自动持久化。

UGC 视频制作的状态图大概长这样:

意图分析 ──→ 搜索对标 ──→ 选择对标 ──→ 制定策略 ──→ 审核策略
                ↑          │                          │
                └── 换方向 ─┘              不满意 ←────┘
                                            │
                                                      搭建脚本工作流 ──→ 审核脚本 ──→ 达人选角 ──→ 选择达人
                    ↑              │                        │
                    └── 不满意 ────┘              不满意 ←──┘
                                                   │
                                    ┌── 盲盒 ──────┤ 满意
                                    ↓              ↓
                              生成视频素材    生成达人定型图 ──→ 审核定型图
                                    ↑                            │
                                    └────────────────────────────┘
                                    ↓
                               审片 ──→ 完成
                                    ↑    │
                                    └────┘ 不满意

13 个步骤,6 个用户确认门控点。每个门控点都有"满意往前走"和"不满意回退重做"两条路。

推进方式分两种:

MANUAL:Agent 主动调 sop(action="advance", step_data={"approved": true}) 推进。用在需要等用户决策的步骤。

AUTO:Agent 调了指定的 tool 后,外层 loop 自动检测并推进。比如 cast_avatar 步骤标记了 auto_advance_on=("match_avatar",),Agent 调完 match_avatar 后 loop 自动推进到 select_avatar。用在 Agent 调完 tool 就该进下一步的场景。

这套东西跑起来之后,状态转换、条件路由、回退都没问题。但很快碰到了一个更深层的问题。

踩坑:instruction 注入和状态推进的时序脱节

状态机的 instruction 是在每轮 _process_message 开头注入的:

_process_message 开始
    │
    ├── 读 session.metadata["sop"] → 当前步骤 A
    ├── build_context(A) → 生成 A 的 instruction
    ├── 注入到 messages
    │
    ├── 启动 executor 循环(LLM ↔ tool 迭代)
    │       │
    │       ├── LLM 调 sop(advance) → 状态 A → B
    │       ├── LLM 继续回复(但 messages 里还是 A 的 instruction)
    │       └── executor 结束
    │
    ├── auto-advance 检查
    └── 返回回复给用户

问题很明显:instruction 注入发生在 executor 循环之前,状态推进发生在 executor 循环之中。 推进之后,LLM 在同一轮里看不到新步骤的 instruction。

这不是一个边缘 case,而是每次状态推进都会遇到的问题。Agent 推进到新步骤后,要么瞎猜下一步该做什么,要么说一句"好的,下一步我来处理"然后等用户再发一条消息——白白浪费一轮交互。

研究了 5 个框架的做法

带着这个问题去看了 LangGraph、CrewAI、OpenAI Agents SDK、Burr、Google ADK 的源码。

发现一个关键共识:所有框架都是"状态转换 = 新一轮 LLM 调用"。没有任何框架把新 instruction 塞到 tool result 里。

但实现方式分成三个流派:

流派一:Break & Rebuild

LangGraph 和 CrewAI 的做法。每个步骤是一个独立的函数,自己构建 LLM 请求。框架只管状态转换和数据传递,不管 prompt 怎么写。

┌─────────┐  state  ┌─────────┐  state  ┌─────────┐
│ Node A  │ ──────→ │ Node B  │ ──────→ │ Node C  │
│         │         │         │         │         │
│ 自己的   │         │ 自己的   │         │ 自己的   │
│ prompt  │         │ prompt  │         │ pr│
│ 自己的   │         │ 自己的   │         │ 自己的   │
│ LLM call│         │ LLM call│         │ LLM call│
└─────────┘         └─────────┘         └─────────┘

CrewAI 更激进——每个 task 执行前 messages.clear(),完全重置对话历史。前一个 task 的输出作为字符串拼到下一个 task 的 prompt 里。干净,但丢失了对话的连续性。

这种方式不适合我们。我们的 Agent 是一个持续对话的助手,用户在整个 SOP 过程中跟同一个 Agent 聊天,对话上下文不能断。

流派二:Swap & Continue

OpenAI Agents SDK 的做法,跟我们的架构最接近。

while True:
    system_prompt = current_agent.get_system_prompt()
    response = call_llm(system_prompt, items)

    if handoff:
        current_agent = new_agent    ← swap
        continue                     ← 下一轮用新 prompt
    if final:
        break

一个 while True 循环。每轮开头从 current_agent 获取 system prompt。发生 handoff 时 swap 指针,continue 回到循环顶部,下一轮自然用新 agent 的 instruction。对话历史通过 input_filternest_handoff_history 控制携带多少。

关键细节:handoff 发生时,当前轮的 LLM 回复会被保存到 items 里,然后才 swap。所以新 agent 能看到上一个 agent 说了什么。

流派三:Halt & Return

Burr 的做法。框架在状态边界主动 yield 控制权给调用方。

action, result, state = app.run(halt_after=["step_a"])
# 调用方拿到结果,决定下一步
action, result, state = app.run(halt_after=["step_b"], inputs={...})

每个 action 是独立函数,自己构建 LLM 调用。框架不管 prompt,只管状态转换和 halt 控制。适合人工介入的场景,但需要外部调用方来驱动循环。

核心洞察

三个流派的实现差异很大,但底层逻辑完全一致:

每次状态转换,都会产生一次新的 LLM 调用,新步骤的 instruction 在这次新调用的 prompt 里注入。

变化的只是"谁来触发这次新调用"——是框架自动触发(LangGraph 的 node 调度),还是循环内 swap 后 continue(OpenAI SDK),还是外部调用方重新调用(Burr)。

最终方案:外层循环 + instruction 作为 user message

借鉴 OpenAI Agents SDK 的 swap + continue 模式,在 _process_message 里加了一个外层循环:

用户消息进来
    │
    ▼
┌─ 外层循环(SOP 步骤级)──────────────────────────┐
│                                                   │
│  读当前步骤 → 记录 step_before                     │
│  注入 instruction                                 │
│      │                                            │
│      ▼                                            │
│  ┌─ 内层循环(executor,tool 调用级)───────────┐  │
│  │                                              │  │
│  │  调 LLM → 有 tool_calls?                    │  │
│  │  ├── 是 → 执行 tool → 追加结果 → 再调 LLM   │  │
│  │  └── 否 → 输出文字 → break                   │  │
│  │                                              │  │
│  └──────────────────────────────────────────────┘  │
│      │                                            │
│      ▼                                            │
│  auto-advance 检查                                │
│  step_before vs step_after                        │
│  ├── 相同 → break,返回回复给用户                  │
│  └── 不同 → 状态推进了                             │
│       ├── 保存这轮对话到 session                   │
│       ├── 新步骤的 instruction → 下一轮 user msg   │
│       └── continue                                │
│                                                   │
└───────────────────────────────────────────────────┘
    │
    ▼
返回最后一轮回复

状态推进后,把新步骤的 build_context(state) 输出直接作为下一轮的 user message。这个输出包含进度条、当前步骤 instruction、已收集的数据。对 LLM 来说就像用户发了一条新消息。

走一遍实际流程感受一下:

外层循环 #1:
  当前步骤: review_strategy
  用户说: "策略定了"
  Agent 调 sop(advance, {approved: true}) → 推进到 build_script
  Agent 回复: "好的,策略确认了"
  step 变了 → 保存这轮 → continue

外层循环 #2:
  当前步骤: build_script
  user message = build_script 的 instruction
  Agent 看到: "调 subagent 搭建脚本仿写工作流"
  Agent 调 subagent → 搭建 + 运行工作流
  Agent 回复脚本结果
  auto-advance → review_script
  step 变了 → 保存 → continue

外层循环 #3:
  当前步骤: review_script
  Agent 回复: "脚本出来了,你看看,顺便上传产品图"
  step 没变 → break → 返回给用户

用户发了一条"策略定了",Agent 自动走完了三个步骤,最终停在需要用户确认的地方。中间没有任何"瞎猜"的轮次。

为什么 instruction 要放在末尾 user message

外层循环解决了"什么时候注入",但还有一个问题:instruction 放在 messages 数组的什么位置。

这个问题需要从两个维度分析。

维度一:Prompt Caching

OpenAI、Anthropic、Google 三家的 Prompt Caching 都基于前缀匹配。从 messages 数组第一个 token 开始连续匹配已缓存的序列,某个位置内容变了,后面全部失效。

三种放法的缓存表现:

方案 A: instruction 放 system prompt 末尾
  [system(静态 + instruction)] → [history] → [user]
  system 每轮变 → 全部缓存失效

方案 B: instruction 放 history 前的独立 user message
  [system(静态)] → [user_dynamic(instruction)] → [history] → [user]
  system 不变 ✓,但 dynamic msg 变 → history 全部失效

方案 C: instruction 放最后一条 user message
  [system(静态)] → [history] → [user(instruction + 输入)]
  system 不变 ✓,history 不变 ✓,只有末尾一条是新的 ✓

Anthr有级联效应——system prompt 变了,system cache 和 messages cache 都 miss。缓存命中成本是基础输入的 10%,失效代价很高。

方案 C 的缓存失效范围最小,只有最后一条消息是增量计算。

维度二:LLM 注意力分布

斯坦福的 “Lost in the Middle”(Liu et al., 2023)发现了 U 型注意力曲线:LLM 对上下文开头和末尾的信息注意力最强,中间最弱。中间位置性能下降超过 20%,在某些设置下甚至低于不提供任何文档的基线。

Anthropic 官方给了量化数据:将查询放在末尾可以提升最多 30% 的响应质量。

方案 B 的 dynamic message 放在 history 前面,随着对话增长会逐渐滑入 messages 数组的中间位置,注意力衰减。方案 C 永远在末尾,不受对话长度影响。

综合对比

维度 A. System 末尾 B. History 前 C. 末尾 User Message
System cache ✓ hit
History cache ✗ 全部 miss ✗ 全部 miss ✓ 全部 hit
注意力强度 最强 中等,随对话衰减 很强,稳定
成本 最高 最低

方案 A 注意力最强但缓存全废,方案 B 两头不讨好,方案 C 是唯一一个在两个维度都不妥协的。

实际注入时用 XML 标签包裹,跟用户输入明确分隔:

<sop_instruction>
# 当前 SOP: UGC 视频制作

## 进度
  [x] 意图分析
  [x] 搜索对标
  [>] 制定创意策略  ← 当前

## 当前步骤: 制定创意策略
先调用 media_understanding 分析对标视频...

## 已收集数据
  - requirements: 唇釉UGC带货视频,投TikTok美国市场
  - reference_video: NYX Lip IV...
</sop_instruction>

---

策略定了,开始写脚本

LLM 看到这条消息就知道当前在哪一步、该做什么、之前收集了什么、用户说了什么。

几个工程细节

无限循环防护_MAX_SOP_LOOPS = 5。正常流程不,但 SOP 定义如果有 bug(比如两个步骤互相跳转),需要兜底。

Memory 只算一次:长期记忆检索(_retrieve_memory)基于用户原始消息,外层循环的后续轮不需要重复检索。Context summary 同理。

Session 持久化:每轮循环结束都保存到 session。下一轮 session.get_history() 能拿到上一轮的完整对话,LLM 能看到自己之前说了什么、调了什么 tool。

SSE 事件流:外层循环的多轮 executor 都在同一个 SSE 连接里。前端看到的是一个连续的事件流,包含 sop.state(当前状态快照)和 sop.transition(步骤切换通知)事件,可以实时更新进度 UI。

这套方案跑了一段时间,状态推进后 Agent 能立即看到新 instruction 并执行,不再有"瞎猜"的问题。本质上就是 OpenAI Agents SDK 的 handoff 模式——检测到状态变化 → 保存当前轮 → 用新 instruction 重新跑一轮。他们是 swap agent 指针,我们是 swap SOP 步骤的 instructio