消息引用模型
四条消息链的结构与引用关系——为什么 mutate 是永久污染、push 是真临时、替换数组要看元素引用
为什么这页要放第二
绝大部分“我改了 messages 但下一步又变回来了”或“我以为是临时的却永久污染了对话”的问题,根因都在这一页讲的 AI SDK 内部维护的四条消息链 以及它们之间的浅引用关系。
读完这页你能回答:
- “我在
prepareStep里.push了一条消息,下一步还在吗?” - “
stepInputMessages是从哪里来的?每步重建吗?” - “改
responseMessages安全吗?” - “
prepareStepResult.messages返回后会被写回哪里?”
四条消息链
以下锚点指向 ai@7.0.8 的 streamText 路径(generate-text/stream-text.ts:1732-2328,异步 streamStep
函数)。generateText 路径结构完全一致但位置不同(generate-text/generate-text.ts:1340-1350);下文引用的语义在两条路径通用。
1. initialMessages —— 入参,整次调用不变
const initialMessages = initialPrompt.messages; // stream-text.ts:1589
来源:agent.stream({ messages }) 传入的数组,经过 standardizePrompt 归一化后保存。
生命周期:整次 agent.stream() 调用期间不变。SDK 不会主动往里添加元素。
引用:数组引用固定;内部消息对象引用也固定——这是后面所有陷阱的根源。
2. responseMessages —— 循环内累加,只接 assistant / tool
// 初始化为空数组
const initialResponseMessages = []; // stream-text.ts:1090
// ... 每步结束、继续下一步前 push ...
responseMessages.push(...(await toResponseMessages({ content, tools }))); // stream-text.ts:1318
来源:SDK 在每步循环里通过 toResponseMessages(...) 辅助函数把本步内容转成 role: "assistant" + role: "tool"
消息,push 到累加数组。
重要约束:toResponseMessages 只产出 role: "assistant" 和 role: "tool"——SDK 从不往 responseMessages push
role: "user"。这是 prepareStep 里 push user 消息能“自动消失”的机制根源。
3. stepInputMessages —— 每步重建,浅展开
const stepInputMessages = [...initialMessages, ...responseMessages]; // stream-text.ts:1801
来源:每步循环开始时,SDK 重新构造这个数组,把它传给 prepareStep 的 messages 参数。
三个关键性质:
- 每步重建——
[...a, ...b]展开运算符创建新数组。外层 identity 变了。 - 浅引用——元素(消息对象)不是深拷贝,仍然是
initialMessages[i]和responseMessages[j]中的同一对象。 - 改元素字段 = 改原始链——对
stepInputMessages[k].content的任何 mutate 都直接影响initialMessages[k]指向的那个对象。
4. prepareStepResult.messages —— 仅本步有效
const prepareStepResult = await prepareStep?.({ ..., messages: stepInputMessages }); // stream-text.ts:1806
// ...
const stepMessages = prepareStepResult?.messages ?? stepInputMessages; // stream-text.ts:1852 —— 只用于本步模型调用
来源:你的 prepareStep 函数返回的 { messages }。
关键性质:
- 这份数组仅用于本步的模型调用(
convertToLanguageModelPrompt的输入)。 - 调用结束后 SDK 不会写回
initialMessages也不会写回responseMessages。 - 下一步重建
stepInputMessages时,回到[...initialMessages, ...responseMessages]——你本步对返回数组的增删完全消失。
引用关系图
核心观察:stepInputMessages 和 prepareStepResult.messages
都是“每步新数组”,但元素对象的引用来自何处决定了修改的生命周期。
三种修改方式的生命周期对照
| 操作 | 本步模型看得到 | 下步 stepInputMessages 还在吗 | 原因 | 评级 |
|---|---|---|---|---|
mutate 已有消息对象字段msg.content += 'x'msg.content.push(part) | 是 | 是——永久污染 | 对象引用共享,mutate 穿透到 initialMessages[k] | 评级:几乎总是 bug |
替换整个 messages 数组return { messages: [...otherArray] } | 是 | 取决于数组元素来源: - 来自 initialMessages/responseMessages 的引用仍共享- 新建对象则不共享 | 新数组本身丢弃,但被 mutate 的对象继续共享 | 评级:看情况 |
push 新消息对象到返回数组prep.push({ role: "user", content }) | 是 | 否——下步消失 | 新对象未进 initialMessages 也不会进 responseMessages(后者只接 assistant/tool) | 评级:真临时注入的正解 |
删除一条已有消息return { messages: prep.filter(...) } | 是(看不到被删的) | 是(原链还在) | 返回数组不写回,stepInputMessages 下步重建仍有被删的那条 | 评级:误导性——只隐藏不删除 |
规则提炼:
- 想“本步临时”:返回新数组或 push 新对象。
- 想“永久修改对话”:不要放在
prepareStep——在agent.stream()之前改messages入参。 - 绝对不要 mutate 已有消息对象的字段。
为什么是浅引用而不是深拷贝
AI SDK 选择浅引用是刻意的:
- 性能:长对话(100+ 消息、超大 content)每步深拷贝成本不可接受。
- 一致性:所有上游调用方(engine 层、业务层)持有的消息对象和 SDK 内部看到的是同一份,便于持久化和日志观测。
- 契约明确:文档声明
prepareStep里 “don’t mutate messages”(官方警告,但措辞不显眼)。
代价是开发者必须手动尊重不变性——SDK 不帮你守,错了自己承担。
实践规则
prepareStep: ({ messages }) => {
// WRONG — 绝对不要
messages[messages.length - 1].content += "注入的提示";
messages[0].content.push({ type: "text", text: "..." });
// OK — 真临时注入
return {
messages: [...messages, { role: "user", content: "注入的提示(仅本步)" }],
};
// OK — 本步过滤(新数组,不改原对象)
return {
messages: messages.filter((m) => m.role !== "tool"),
};
// OK — 本步替换某条(创建新对象)
return {
messages: messages.map((m, i) => (i === messages.length - 1 ? { ...m, content: reformat(m.content) } : m)),
};
};
一个常被忽视的细节:累加的 response 消息的回写时机
在 streamText 路径下:累加的 response
消息在一步结束、进入下一步之前推进(stream-text.ts:1317 的 recordedSteps.push;1318-1321 的
stepMessagesForNextStep),onStepEnd(v6: onStepFinish,仍是弃用别名)在此之前通过下游 transform 触发(事件流处理器
stream-text.ts:1273-1325)。也就是说:
prepareStep(n+1)看到的stepInputMessages已经包含 step n 生成的 assistant / tool 消息onStepEnd(n)的 payloadstepResult.response.messages是cloneModelMessages(stepResponseMessages)——深克隆(stream-text.ts:1301)
在 generateText 路径下:stepResult.response.messages 同样是 cloneModelMessages(stepResponseMessages)
深克隆(generate-text.ts:1291)。
v7 变化:从
ai@7.0.8起两条路径都对本步的 response 消息做深克隆写入stepResult.response.messages,所以在onStepEnd里 mutate 它们不会穿透到下一步。早期版本给 streamText 的是活引用。保守规则依然成立:不要依赖在回调里 mutate 消息——把它们当只读处理。
延伸阅读
- prepareStep 语义 — 基于本页的引用模型,展开
prepareStep的所有典型用法和陷阱 - 运行生命周期 — 消息链在时间上的运转全景
- SDK 源码锚点(
ai@7.0.8,路径在src/下):- streamText 路径:
generate-text/stream-text.ts:1732-2328(streamStep循环);1806-1818(prepareStep 调用点);1794-1804(accumulatedResponseMessages/stepInputMessages,喂给下一步) - generateText 路径:
generate-text/generate-text.ts:1340-1350(do-while 循环);805-817(prepareStep 调用) - 下游 transform / stepResult 构造:
generate-text/stream-text.ts:1273-1325(currentStepResult构造);1306-1309(onStepEnd/onStepFinish触发);1317(recordedSteps.push)
- streamText 路径: