消息引用模型
四条消息链的结构与引用关系——为什么 mutate 是永久污染、push 是真临时、替换数组要看元素引用
为什么这页要放第二
绝大部分”我改了 messages 但下一步又变回来了”或”我以为是临时的却永久污染了对话”的问题,根因都在这一页讲的 AI SDK 内部维护的四条消息链 以及它们之间的浅引用关系。
读完这页你能回答:
- “我在
prepareStep里.push了一条消息,下一步还在吗?” - “
stepInputMessages是从哪里来的?每步重建吗?” - “改
responseMessages安全吗?” - “
prepareStepResult.messages返回后会被写回哪里?“
四条消息链
以下锚点指向 ai@6.0.134 的 streamText 路径(dist/index.js:7030-7630)。generateText 路径结构完全一致但行号不同(对应片段在 4210-4640);下文引用的语义在两条路径通用。
1. initialMessages —— 入参,整次调用不变
const initialMessages = initialPrompt.messages; // dist:7036
来源:agent.stream({ messages }) 传入的数组,经过 standardizePrompt 归一化后保存。
生命周期:整次 agent.stream() 调用期间不变。SDK 不会主动往里添加元素。
引用:数组引用固定;内部消息对象引用也固定——这是后面所有陷阱的根源。
2. responseMessages —— 循环内累加,只接 assistant / tool
// 初始化为空数组
const initialResponseMessages = []; // dist:7037
// ... 每步结束、继续下一步前 push ...
responseMessages.push(...await toResponseMessages({ content, tools })); // dist:7623
来源: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]; // dist:7186
来源:每步循环开始时,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 }); // dist:7187
// ...
const stepMessages = prepareStepResult?.messages ?? stepInputMessages; // dist:7216 —— 只用于本步模型调用
来源:你的 prepareStep 函数返回的 { messages }。
关键性质:
- 这份数组仅用于本步的模型调用(
convertToLanguageModelPrompt的输入)。 - 调用结束后 SDK 不会写回
initialMessages也不会写回responseMessages。 - 下一步重建
stepInputMessages时,回到[...initialMessages, ...responseMessages]——你本步对返回数组的增删完全消失。
引用关系图
┌───────────────────────────────────────────────────────────────────┐
│ 整次 agent.stream() 调用 │
│ │
│ initialMessages (不变) responseMessages (循环内累加) │
│ ┌─────────────────────┐ ┌───────────────────────────┐ │
│ │ User msg 1 │ │ (step1) assistant msg │ │
│ │ User msg 2 │ │ (step1) tool result │ │
│ │ Assistant msg (旧) │ │ (step2) assistant msg │ │
│ │ ... │ │ (step2) tool result │ │
│ └──────────┬──────────┘ └──────────────┬────────────┘ │
│ │ │ │
│ │ 浅展开(共享元素引用) │ │
│ ↓ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ stepInputMessages (每步新数组,元素仍是共享引用) │ │
│ │ = [...initialMessages, ...responseMessages] │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ │
│ │ 作为 messages 参数传入 │
│ ↓ │
│ ┌────────────────────┐ │
│ │ prepareStep({...}) │ │
│ └──────────┬─────────┘ │
│ │ │
│ │ 可返回 { messages: ... } │
│ ↓ │
│ ┌────────────────────────────────────────┐ │
│ │ prepareStepResult.messages (仅本步) │ │
│ │ 只用于 convertToLanguageModelPrompt │ │
│ │ SDK 不写回 initialMessages/Response │ │
│ └────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
核心观察: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
),
};
};
一个常被忽视的细节:responseMessages 的回写时机
在 streamText 路径下:responseMessages.push(...) 发生在一步结束、进入下一步之前(dist:7623),onStepFinish 在此之前通过下游 transform 触发(事件流处理器 dist:6776-6810)。也就是说:
prepareStep(n+1)看到的stepInputMessages已经包含 step n 生成的 assistant / tool 消息onStepFinish(n)的 payloadstepResult.response.messages是[...recordedResponseMessages, ...stepMessages](新数组,但元素是活引用——dist:6794)- 两者共享同一批对象引用——在 streamText 的
onStepFinish里 mutate 这些消息对象,下一步的stepInputMessages会看到污染
在 generateText 路径下:stepResult.response.messages 是 structuredClone(responseMessages) 深克隆(dist:4602)——mutate 不会穿透,是安全的。
实战规则: 如果你不确定自己用的是哪条路径,就假设是 streamText 并遵循”不要 mutate messages”。这个保守约定的代价只是多一次浅拷贝,换来”代码从 generateText 迁到 streamText 时不会爆”。
延伸阅读
- prepareStep 语义 — 基于本页的引用模型,展开
prepareStep的所有典型用法和陷阱 - 运行生命周期 — 消息链在时间上的运转全景
- SDK 源码锚点(
ai@6.0.134):- streamText 路径:
dist/index.js:7030-7630(step loop + prepareStep 调用点);7623(responseMessages反哺 stepInputMessages 的 push 点) - generateText 路径:
dist/index.js:4210-4640(结构对应,行号不同) - 下游 transform / stepResult 构造:
dist/index.js:6649-6810(recordedResponseMessages、onStepFinish触发点)
- streamText 路径: