消息引用模型

四条消息链的结构与引用关系——为什么 mutate 是永久污染、push 是真临时、替换数组要看元素引用

为什么这页要放第二

绝大部分”我改了 messages 但下一步又变回来了”或”我以为是临时的却永久污染了对话”的问题,根因都在这一页讲的 AI SDK 内部维护的四条消息链 以及它们之间的浅引用关系

读完这页你能回答:

  • “我在 prepareStep.push 了一条消息,下一步还在吗?”
  • stepInputMessages 是从哪里来的?每步重建吗?”
  • “改 responseMessages 安全吗?”
  • prepareStepResult.messages 返回后会被写回哪里?“

四条消息链

以下锚点指向 ai@6.0.134streamText 路径(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 重新构造这个数组,把它传给 prepareStepmessages 参数。

三个关键性质

  1. 每步重建——[...a, ...b] 展开运算符创建新数组。外层 identity 变了。
  2. 浅引用——元素(消息对象)不是深拷贝,仍然是 initialMessages[i]responseMessages[j] 中的同一对象。
  3. 改元素字段 = 改原始链——对 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     │             │
│            └────────────────────────────────────────┘             │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

核心观察stepInputMessagesprepareStepResult.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) 的 payload stepResult.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 调用点);7623responseMessages 反哺 stepInputMessages 的 push 点)
    • generateText 路径: dist/index.js:4210-4640(结构对应,行号不同)
    • 下游 transform / stepResult 构造: dist/index.js:6649-6810recordedResponseMessagesonStepFinish 触发点)
这页有帮助吗?