chunk → parts 引擎:StreamProcessor 如何拼出 UIMessage
30 秒导读: 上一章的 connection adapter 把服务端的线协议解成一串 AG-UI chunk 事件(
TEXT_MESSAGE_CONTENT、TOOL_CALL_ARGS……)。这一章的StreamProcessor是把这串增量、乱序、会丢事件的流,一条条累加成 UI 真正拿去渲染的那个数据结构——UIMessage.parts。它是整个前端"看得见的消息"的唯一来源。
本章讲透 packages/ai/src/activities/chat/stream/ 这个目录:核心是 processor.ts 里约 2120 行的 StreamProcessor 类,配套是 message-updaters.ts(纯函数改 parts)、strategies.ts(节流)、json-parser.ts(容忍半截 JSON)、types.ts(内部状态类型)。
这些 parts 怎么被 hook 消费在第 4 章,怎么被渲染成 DOM 在第 5 章。本章只负责一件事:把 chunk 变成 parts。
1. 这是什么(零基础也能懂)
1.1 先认识产物:parts 化的消息
在很多聊天 SDK 里,一条 AI 消息就是一个字符串 content。TanStack AI 不是——它的一条消息是一个 parts 数组:文本、思考、工具调用、工具结果、结构化输出、图片……每一样都是数组里的一个 part。
UIMessage 的形状(packages/ai-client/src/types.ts:262 UIMessage,运行时同构定义在 packages/ai/src/types.ts:455):
UIMessage {
id: "msg_abc"
role: "assistant"
parts: [
{ type: "thinking", content: "我先查一下天气……" }
{ type: "text", content: "让我帮你查一下。" }
{ type: "tool-call", id: "call_1", name: "getWeather", state: "input-complete", arguments: '{"city":"上海"}' }
{ type: "tool-result", toolCallId: "call_1", content: "18°C 多云" }
{ type: "text", content: "上海现在 18 度,多云。" }
]
}
为什么要拆成 parts? 因为 UI 要对不同内容做不同的事:思考块折叠、工具调用画成卡片、文本做 markdown 渲染。一坨字符串做不到,parts 数组天然给了 UI 分类渲染的抓手。
parts 的类型定义(packages/ai/src/types.ts:436 MessagePart):
| part 类型 | 装什么 | 关键字段 |
|---|---|---|
text | 助手回复正文 | content |
thinking | 推理/思考内容 | content · stepId · signature |
tool-call | 一次工具调用 | id · name · arguments(JSON 串)· state · output · approval |
tool-result | 工具执行结果(喂回 LLM 用) | toolCallId · content · state |
structured-output | 流式结构化输出 | status · raw · partial · data |
ui-resource | MCP Apps 的 ui:// 组件 | resource · toolCallId · toolName |
image/audio/video/document | 多模态 | 各自的 source |
1.2 再认识难点:输入是"碎的、乱的、可能缺的"
StreamProcessor 拿到的不是这个漂亮结构,而是一串碎片事件。同样一条消息,在流里长这样:
TEXT_MESSAGE_START { messageId: "msg_abc" }
TEXT_MESSAGE_CONTENT { delta: "让我" }
TEXT_MESSAGE_CONTENT { delta: "帮你" }
TEXT_MESSAGE_CONTENT { delta: "查一下。" }
TOOL_CALL_START { toolCallId: "call_1", toolCallName: "getWeather" }
TOOL_CALL_ARGS { toolCallId: "call_1", delta: '{"ci' }
TOOL_CALL_ARGS { toolCallId: "call_1", delta: 'ty":"上海"}' }
TOOL_CALL_END { toolCallId: "call_1" }
RUN_FINISHED { runId: "run_1" }
难点有三层,后面每一节都在解决它们:
- 增量: 文本和工具参数都是一小段一小段来的,要累加。
- 半截: 工具参数
{"ci还不是合法 JSON,但 UI 想边流边预览,需要容错解析。 - 可能缺:
TOOL_CALL_END可能因为适配器 bug 或断流永远不来,状态机必须 有兜底。
1.3 一句话直觉
把 StreamProcessor 想成一个专门给聊天流做的 reducer: state 是 UIMessage[],每个 chunk 是一个 action,processChunk 是 dispatch,每个 handler 就是一个 case,message-updaters.ts 里的纯函数是 reducer 本体。每次 state 变了就 onMessagesChange 通知外面重渲染。
2. 顶层全景(它大概怎么转)
2.1 一张图:从 chunk 到 UI
先看整条数据流。从上到下读:事件进来,分派到 handler,handler 调纯函数改 parts,改完广播出去。
connection adapter(第 2 章)吐出的 chunk 流
│
▼
┌──────────────────────────────────────────────┐
│ StreamProcessor.process(stream) │
│ for await (chunk of stream) processChunk() │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ processChunk(): 一个大 switch(chunk.type) │ ← 中央分派
│ TEXT_* │ TOOL_CALL_* │ REASONING_* │ CUSTOM │
│ RUN_* │ STEP_* │ MESSAGES_SNAPSHOT │ ... │
└──────────────────────────────────────────────┘
│ 每个 case → 一个 handler
▼
┌──────────────────────────────────────────────┐
│ handler:更新两处状态 │
│ (a) messageStates → 每条消息的流式草稿状态 │
│ (b) this.messages → 通过 message-updaters │
│ 纯函数拼出的 UIMessage[] │
└──────────────────────────────────────────────┘
│
▼
events.onMessagesChange([...messages]) ← 广播全量数组
│
▼
useChat(第 4 章) → UI 组件(第 5 章)渲染
怎么读这张图: 中间那个 switch 是心脏;它左手维护一份"内部草稿"(messageStates),右手把草稿投影成对外的 UIMessage[],每次投影完就整份广播出去。
2.2 五个文件各干什么
| 文件 | 职责 | 关键符号 |
|---|---|---|
processor.ts | 状态机主体:分派 chunk、维护流式状态、拼装 parts | StreamProcessor · processChunk |
message-updaters.ts | 一组纯函数,输入旧 messages + 一个改动,返回新 messages | updateTextPart · updateToolCallPart |
strategies.ts | 决定"文本累加到什么程度才广播一次",做 UI 节流 | ImmediateStrategy · PunctuationStrategy |
json-parser.ts | 用 partial-json 库解析半截的工具参数 | parsePartialJSON |
types.ts | 内部状态类型(不是对外的 UIMessage) | MessageStreamState · InternalToolCallState |