prepareStep 语义

每步前置钩子的完整签名、7 个可覆盖字段、4 种典型模式,以及 mutate-vs-push 陷阱的深度剖析

为什么 prepareStep 是最关键的钩子

Agent 运行时的上下文工程几乎全部发生在 prepareStep 里:

  • 压缩长上下文(token 到阈值后触发 summary / tier 化裁剪)
  • 注入 system-reminder(临时约束、不进对话历史)
  • 动态过滤工具可见性(activeTools 筛选——大工具集里每步只放少量候选)
  • 强制工具调用(toolChoice: "required" 阻止早退)
  • 动态切模型(规划用便宜模型、执行用强模型)
  • 应用 cache control 断点(Anthropic prompt cache)

这也是最容易踩的钩子——引用语义稍不注意就污染整个对话。这页把 API 全签名和常见模式系统梳理,末尾用真实代码对比 mutate-vs-push 陷阱。

前置:请先读 消息引用模型。本页假设你知道 stepInputMessagesprepareStepResult.messages 的差异。

完整签名

type PrepareStepCallback = (options: {
  model: LanguageModel;             // 当前步要用的 model(可被本调用覆盖)
  steps: StepResult[];              // 已完成的历史步(StepResult 数组,不含当前步)
  stepNumber: number;               // 当前步索引(0 起)
  messages: ModelMessage[];         // stepInputMessages —— 见"消息引用模型"
  experimental_context: unknown;    // 跨层透传的 context(业务方自定义 shape)
}) => Promise<{
  model?: LanguageModel;            // 本步切模型
  system?: string;                  // 本步覆盖 system prompt
  messages?: ModelMessage[];        // 本步发给模型的 messages(仅本步)
  toolChoice?: ToolChoice;          // 本步 tool 选择策略
  activeTools?: string[];           // 本步可见工具名单(tool filtering)
  providerOptions?: ProviderOptions;// 本步 provider 选项(与 L1/L2 的 providerOptions 合并)
  experimental_context?: unknown;   // 本步覆盖 context(罕见)
}> | undefined;

返回 undefined 或不返回:SDK 沿用本次调用的默认值(stepInputMessages、settings 上的 model、activeTools、toolChoice 等)。

返回部分字段:SDK 用你的字段覆盖对应默认值,其他保持默认(streamText 路径 dist/index.js:7195-7220;generateText 对应 4322-4340)。

7 个可覆盖字段对照

字段覆盖什么典型用途覆盖的粒度
model本步用的 LLM轻/重活分流、fallback仅本步
system本步的 system promptA/B 提示词、基于上下文调整人设仅本步
messages发给模型的 messages压缩、reminder 注入、过滤仅本步
toolChoice工具选择策略"required" 强制调用、"none" 禁用、{ type: "tool", toolName } 强制指定仅本步
activeTools本步可见工具名单tool search pool 收敛、按上下文隐藏危险工具仅本步
providerOptionsprovider-specific 选项(与 L1/L2 的 providerOptions 合并,不是替换——dist:7219动态切 Anthropic cache_control 位置、按步调整 reasoning / thinking budget、按步切 OpenAI seed仅本步
experimental_context下游 tool.execute 能看到的 context按步传递状态(罕见——通常 L1 一次性设定)仅本步
(无 tools——工具集合在 L1/L2 已固定,prepareStep 只能 filter,不能新增——

关键约束prepareStep 无法新增工具——只能通过 activeTools 从 L1/L2 固定的 ToolSet 里筛选。如果你希望”按步动态工具集”,要么全部工具在 L1 注册、用 activeTools 筛,要么用 prepareCall 动态替换整个 tools(见 生命周期 → prepareCall)。

4 种典型模式

模式 1:reminder 注入(临时上下文)

想在每步给模型一个临时”系统提醒”(eg. “不要用 emoji”、“优先使用内部工具”),但不想让它污染对话历史:

prepareStep: async ({ messages, experimental_context }) => {
  const ctx = experimental_context as MyContext;
  if (ctx.reminders.length === 0) return undefined;

  const remindersText = ctx.reminders
    .map(r => `<system-reminder>\n${r}\n</system-reminder>`)
    .join('\n');

  // OK — 追加新消息,本步可见,下步自动消失
  return {
    messages: [
      ...messages,
      { role: 'user', content: `[system directive — not user input]\n${remindersText}` },
    ],
  };
},

为什么这样写消息引用模型 里第三行——push 新消息是唯一”下步自动消失”的模式。

模式 2:工具收敛(大工具集 → 小候选)

“tool search pool” 模式——当 agent 接了 MCP 或动态发现的工具源时,可用工具可能有几百个;但每步模型只应该看到和当前上下文相关的一小撮(降低 token 消耗,提高选择准确率):

prepareStep: async ({ experimental_context }) => {
  const ctx = experimental_context as MyContext;
  const pool = ctx.toolDiscovery;
  if (!pool?.active) return undefined;

  return {
    activeTools: pool.getActiveToolNames(),   // eg. ["read_file", "grep", "tool_search"]
  };
},

为什么不用 toolstools 在 L1 就已注册了全集;activeTools 只是名单过滤——SDK 在 prepareToolsAndToolChoice 里按名字筛(dist/index.js:4330-4334),零成本。

模式 3:强制工具使用(阻止早退)

场景:agent 有一个 todo list,未完成时绝对不能给最终答案:

prepareStep: async ({ experimental_context }) => {
  const ctx = experimental_context as MyContext;
  const hasPendingTodos = ctx.todos?.some(t => t.status !== 'done') ?? false;

  if (hasPendingTodos) {
    return { toolChoice: 'required' };   // 强制本步必须调用工具
  }
  return undefined;
},

什么时候用 toolChoice 的其他值

  • 'auto'(默认):模型自由选择
  • 'required':必须调用某个工具(哪个由模型决定)
  • 'none':禁用工具(纯生成)
  • { type: 'tool', toolName: 'finish' }:强制调用指定工具

典型用法:'required' 做”未完成 todo 阻止退出”、{ type: 'tool', toolName: 'complete' } 在子 agent 收尾阶段强制投递结果、'none' 用于”总结阶段不能再调工具”的最后一步。

模式 4:动态切模型

prepareStep: async ({ model, messages, stepNumber }) => {
  // 前 3 步用便宜模型规划,之后切强模型执行
  if (stepNumber < 3) {
    return { model: haikuModel };
  }
  return { model: sonnetModel };
},

注意事项:切模型不切 tool schema——工具定义是 L1 层的。某些 provider 对 tool schema 格式敏感(OpenAI vs Anthropic),切模型前确保 tool schema 两边都兼容。

mutate-vs-push 陷阱(本章的核心反模式)

这是 消息引用模型prepareStep 的直接应用。下面是一个真实生产案例——最初写法带污染,后来重构成 push 模式:

反例(污染版)

// 反模式:mutate initialMessages 里的对象
prepareStep: async ({ messages }) => {
  const remindersText = ctx.getRemindersText();
  const lastMessage = messages[messages.length - 1];

  if (lastMessage.role === 'user') {
    // WRONG — 直接 mutate,永久污染 initialMessages 里的这条消息
    lastMessage.content += `\n${remindersText}`;
  } else {
    // OK — push 新消息,下步自动消失
    messages.push({ role: 'user', content: remindersText });
  }
  return { messages };
},

错在哪

  • lastMessagestepInputMessages[k] 的引用,和 initialMessages 的用户消息是同一对象。
  • lastMessage.content += ... 创建新字符串赋给 .content 字段——但字段是在原对象上重写,原对象仍在 initialMessages 里。
  • step 1 改完,step 2/3/4 每次 stepInputMessages = [...initialMessages, ...responseMessages] 都把这条被污染的消息展开进去——reminder 一直在。
  • 更糟的是每步都会再次 +=——reminder 堆积指数级增长。

正解(统一 push)

prepareStep: async ({ messages }) => {
  const remindersText = ctx.getRemindersText();
  if (!remindersText) return undefined;

  // OK — 不管 last 是不是 user,都 push 新消息
  return {
    messages: [
      ...messages,
      { role: 'user', content: `[system directive — not user input]\n${remindersText}` },
    ],
  };
},

为什么这么改

  • 每步新建一个 user 消息对象,仅本步可见。
  • 下步重建 stepInputMessages 时回到纯净的 [...initialMessages, ...responseMessages],新消息自动消失。
  • 无累加风险,无共享引用污染。

一句话判决

永远不要 mutate prepareStep 拿到的 messages 元素的字段。

做法判决
msg.content += 'x'污染
msg.content.push(part)污染
msg.metadata = {...}污染
msg.role = 'system'污染
return { messages: [...messages, newMsg] }安全
return { messages: messages.filter(...) }安全(新数组,未改元素)
return { messages: messages.map((m, i) => i === N ? {...m, content: 'new'} : m) }安全(创建新对象代替目标对象)

一次性时序聚焦

sequenceDiagram participant Loop as streamText loop participant P as prepareStep participant Model rect rgba(200, 220, 255, 0.2) Note over Loop: Step n Loop->>Loop: stepInputMessages =<br/>[...initialMessages, ...responseMessages] Loop->>P: prepareStep({ messages: stepInputMessages }) Note over P: WRONG —— 若 mutate messages[k].content:<br/>initialMessages[k] 永久污染 P->>Loop: prepareStepResult.messages<br/>(本步新数组) Loop->>Model: convertToLanguageModelPrompt(messages) Note over Loop: OK —— 返回的新数组仅本步<br/>不写回 initialMessages Model-->>Loop: assistant + tool<br/>(push 到 responseMessages) end rect rgba(255, 220, 200, 0.2) Note over Loop: Step n+1 Loop->>Loop: stepInputMessages 重建<br/>= [...initialMessages, ...responseMessages] Note over Loop: step n 返回的 messages 数组消失<br/>但被 mutate 的对象仍在! Loop->>P: prepareStep({...}) end

性能注意

prepareStep 在每步模型调用前都会执行,且是阻塞的——它返回的 Promise resolve 之前模型调用不会启动。

容易踩的性能坑

  • 同步 I/O(fs.readFileSync、同步 DB 查询)——直接阻塞 event loop。
  • token 计数@anthropic-ai/tokenizer 或 tiktoken)——首次加载模型权重就需要 ~100ms,每步都算一次累加显著。实用做法:缓存上一轮的 token 估算值,只在有变化的消息区段重算。
  • LLM 调用(压缩用的摘要模型)——单次压缩 LLM 调用几秒级,会把每步的等待时间翻倍。实用做法:把”决定是否压缩”和”实际压缩”分开——前者同步判断(token 估算到阈值才触发),后者只在必要时走 LLM。

经验值prepareStep 的同步+异步时间加起来应控制在 < 200ms,否则每 step 都感知得到延迟,20 步任务多累加 4 秒。

prepareCall 的分工

场景prepareCall(每次调用一次)prepareStep(每步一次)
按 user tier 选 model推荐:调用前定一次过度:每步重算
按上下文动态切 model不行:调用开始时还不知道推荐:每步根据 steps / messages 决策
注入 system-reminder不行:reminder 本来就是运行时状态推荐:正确场景
压缩 messages不行:只能整次压一次,不能响应膨胀推荐:token 到阈值才压
替换整个 tools 集合推荐:prepareStep 无法新增工具不行:只能 filter 不能新增

延伸阅读

相关 SDK 章节

Zapvol 落地参考

  • Context Compaction — 基于 prepareStep 的三级压缩系统
  • Tool Search — 基于 prepareStepactiveTools 动态过滤
  • packages/backend/src/agent/agent-stream.tsprepareStep 实装的组装位置
这页有帮助吗?