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 陷阱。
前置:请先读 消息引用模型。本页假设你知道 stepInputMessages 和 prepareStepResult.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 prompt | A/B 提示词、基于上下文调整人设 | 仅本步 |
messages | 发给模型的 messages | 压缩、reminder 注入、过滤 | 仅本步 |
toolChoice | 工具选择策略 | "required" 强制调用、"none" 禁用、{ type: "tool", toolName } 强制指定 | 仅本步 |
activeTools | 本步可见工具名单 | tool search pool 收敛、按上下文隐藏危险工具 | 仅本步 |
providerOptions | provider-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"]
};
},
为什么不用 tools:tools 在 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 };
},
错在哪:
lastMessage是stepInputMessages[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) } | 安全(创建新对象代替目标对象) |
一次性时序聚焦
性能注意
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 — 基于
prepareStep的activeTools动态过滤 packages/backend/src/agent/agent-stream.ts—prepareStep实装的组装位置