一条 assistant 消息在 pi 里不是"一次性生成"的,而是从
pending状态开始,经过若干个text_delta/toolcall_delta事件逐字长大,最终定格成stop/toolUse/error/aborted中的一种终态。本篇讲清楚这套流式状态机是怎么从底层 Provider 一路传导到 Agent 事件、再到上层 UI 的,以及"轮次(turn)"和"中断(abort)"在这套机制里各自扮演什么角色。
学习目标
- 记住 pi 里参与 LLM 上下文的三种基础消息角色(
user/assistant/toolResult),以及coding-agent在此之上扩展出的自定义消息角色(bashExecution/custom/branchSummary/compactionSummary)。 - 理解
AssistantMessageEvent协议------一条流式响应从start到done/error之间会经历哪些事件类型。 - 搞清楚
streamAssistantResponse如何把底层事件流转换成AgentEvent(message_start/message_update/message_end),一路传导给 UI。 - 理解"轮次(turn)"的边界定义,以及
turn_start/turn_end事件覆盖的范围。 - 掌握 steering(打断中插话)、follow-up(排队等待)、abort(中止)三种控制流机制的实现与差异。
背景与设计动机
对话循环要解决的核心问题是:LLM 的响应是流式的、可能耗时数十秒,而人类用户和上层 UI 都需要实时看到进展、并且能随时插话或中止 。如果把"发一个请求,等一个完整响应"当作黑盒操作,用户体验会很差(看不到打字机效果、无法中途纠正方向、无法取消)。所以 pi 的消息模型天生就是"分层状态机":最外层是 AgentMessage 的角色(user/assistant/toolResult/...),内层是 assistant 消息在流式生成过程中的内容块状态(text/thinking/toolCall 各自的 start/delta/end),再往上是若干条消息组成的"轮次",若干轮次组成一次"agent 运行"。
核心机制详解
消息角色的两层体系
第一层,pi-ai(packages/ai/src/types.ts)定义的三种基础角色,是唯一会被发送给 LLM Provider 的角色:
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: ProviderId;
model: string;
usage: Usage;
stopReason: StopReason; // "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred"
errorMessage?: string;
timestamp: number;
}
export interface ToolResultMessage<TDetails = any> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: TDetails;
usage?: Usage;
addedToolNames?: string[];
isError: boolean;
timestamp: number;
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
第二层,packages/coding-agent/src/core/messages.ts 通过声明合并往 AgentMessage 联合类型里加了四种"只在宿主应用内部流转、从不直接发给模型"的角色:
export interface BashExecutionMessage {
role: "bashExecution";
command: string; output: string; exitCode: number | undefined;
cancelled: boolean; truncated: boolean; fullOutputPath?: string;
excludeFromContext?: boolean; // true 时代表 `!!` 前缀命令,完全不进入 LLM 上下文
timestamp: number;
}
export interface CustomMessage<T = unknown> {
role: "custom"; customType: string;
content: string | (TextContent | ImageContent)[];
display: boolean; details?: T; timestamp: number;
}
export interface BranchSummaryMessage { role: "branchSummary"; summary: string; fromId: string | null; timestamp: number; }
export interface CompactionSummaryMessage { role: "compactionSummary"; summary: string; tokensBefore: number; timestamp: number; }
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
custom: CustomMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
}
}
这四种角色分别对应:用户用 !command 直接执行 shell 而不经过模型思考、扩展往会话里注入的自定义内容、/tree 分支切换时生成的旧分支摘要、自动压缩生成的历史摘要(后两者详见第四、五篇)。它们在会话文件里各自是独立的消息角色,但发给模型之前必须先"降级"成标准的 user/assistant/toolResult 三种之一------这正是 convertToLlm 函数的职责:
// packages/coding-agent/src/core/messages.ts
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
if (m.excludeFromContext) return undefined; // `!!` 前缀:直接从上下文中剔除
return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], timestamp: m.timestamp };
case "custom": {
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
return { role: "user", content, timestamp: m.timestamp };
}
case "branchSummary":
return { role: "user", content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], timestamp: m.timestamp };
case "compactionSummary":
return { role: "user", content: [{ type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }], timestamp: m.timestamp };
case "user": case "assistant": case "toolResult":
return m;
default: {
const _exhaustiveCheck: never = m;
return undefined;
}
}
})
.filter((m) => m !== undefined);
}
注意 default 分支里的 const _exhaustiveCheck: never = m;------这是 TypeScript 的穷尽性检查技巧:如果未来有人往 CustomAgentMessages 里加了新角色却忘记在这个 switch 里处理,编译期就会报错。这个函数被 Agent 构造时作为 convertToLlm 选项传入(见第一篇 agent.ts 的 AgentOptions.convertToLlm),是agent-loop.ts 里"引擎只认识 AgentMessage[],只在调用模型前收窄成 Message[]"这句话的具体执行者。
对比之下,packages/agent/src/agent.ts 里的默认实现极其简单粗暴:
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
);
}
如果宿主应用不覆盖这个选项,任何自定义角色的消息都会被直接过滤掉,不会报错------这是一个"默认安全"的兜底行为,自定义消息类型必须显式提供转换逻辑才能真正影响模型看到的上下文。
流式协议:AssistantMessageEvent
一条 assistant 消息从模型侧被生成的过程,由 pi-ai 定义的一组结构化事件描述(packages/ai/src/types.ts):
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">; message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
这组事件描述的是一条 assistant 消息内部,content 数组里每个内容块(文本段、思考段、工具调用段)各自的生命周期 ,和文档注释里说的一样:先 start,然后是任意多个 *_delta,最后以 done(正常结束,stopReason 可能是 stop/length/toolUse/deferred)或 error(aborted/error)收尾。承载这套协议的容器是一个自定义的最小事件流实现,packages/ai/src/utils/event-stream.ts:
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
// ...
push(event: T): void {
if (this.done) return;
if (this.isComplete(event)) { this.done = true; this.resolveFinalResult(this.extractResult(event)); }
const waiter = this.waiting.shift();
if (waiter) waiter({ value: event, done: false });
else this.queue.push(event);
}
async *[Symbol.asyncIterator](): AsyncIterator<T> { /* 队列 + 等待者双缓冲的标准异步迭代器实现 */ }
result(): Promise<R> { return this.finalResultPromise; }
}
它既可以用 for await...of 逐个消费事件(agent-loop.ts 正是这么做的),也可以直接 await stream.result() 拿到最终结果------这就是为什么 agent-loop.ts 里既有 for await (const event of response) 的逐事件处理,又有兜底的 await response.result()。packages/agent/src/agent-loop.ts 顶部也有一个几乎同构的 createAgentStream(),包装的是更高层的 AgentEvent,用同一套"队列 + 等待者"模式对外提供 EventStream<AgentEvent, AgentMessage[]>。
从 Provider 事件到 Agent 事件:streamAssistantResponse
packages/agent/src/agent-loop.ts 里的 streamAssistantResponse 就是"翻译层"------它订阅 StreamFn 返回的 AssistantMessageEventStream,把每个底层事件映射成对外的 AgentEvent:
for await (const event of response) {
switch (event.type) {
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage); // 立即把"占位消息"塞进上下文
addedPartial = true;
await emit({ type: "message_start", message: { ...partialMessage } });
break;
case "text_start": case "text_delta": case "text_end":
case "thinking_start": case "thinking_delta": case "thinking_end":
case "toolcall_start": case "toolcall_delta": case "toolcall_end":
if (partialMessage) {
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage; // 原地替换成最新的部分消息
await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } });
}
break;
case "done": case "error": {
const finalMessage = await response.result();
if (addedPartial) context.messages[context.messages.length - 1] = finalMessage;
else context.messages.push(finalMessage);
if (!addedPartial) await emit({ type: "message_start", message: { ...finalMessage } });
await emit({ type: "message_end", message: finalMessage });
return finalMessage;
}
}
}
三层映射关系是:start → message_start(同时把 partial message 提前放进 context.messages,让同一轮次里后续逻辑------比如 UI 渲染------能立刻看到"有一条消息正在生成");中间的九种细粒度事件全部折叠成统一的 message_update(把具体的 assistantMessageEvent 原样透传出去,UI 层可以按需细分渲染文本增量还是工具调用增量);done/error 都归一到 message_end。这个折叠策略的好处是:UI 层如果只关心"消息内容变了,重新渲染就行",只需要处理 message_start/message_update/message_end 三种事件;如果需要逐字符做特效,才需要进一步 switch assistantMessageEvent.type 。packages/coding-agent 的 TUI 渲染管线正是走的前一条路径为主。
轮次(Turn)的边界
packages/coding-agent/docs/extensions.md 对 turn 的定义是"一次 LLM 响应 + 该响应引发的所有工具调用"。这与 agent-loop.ts 的 runLoop 内层循环体一一对应------内层循环每迭代一次就是一轮:
turn_start
→ streamAssistantResponse() # 一条 assistant 消息(message_start/update/end)
→ executeToolCalls() # 该消息里所有 toolCall 的执行(tool_execution_*、toolResult 的 message_*)
turn_end { message, toolResults }
turn_end 事件把这一轮的 assistant 消息和它引发的全部工具结果一次性打包传出去,这也是 packages/coding-agent 里很多"轮末检查"逻辑的挂载点------比如第五篇要讲的自动压缩判断,就是在处理完 turn_end 之后,检查上下文 token 数是否超过阈值来决定是否要在下一轮之前插入一次压缩。shouldStopAfterTurn 精确挂在"一轮刚结束"这个时间点,直接消费该轮 turn_end 产出的原始上下文;prepareNextTurn 则被推迟到"确定还要继续、下一轮真正开始之前"才执行,两者的调用时机并不重合(第一篇有订正说明这一顺序调整的原因)。
需要注意agent_end 和 agent_settled(后者是 coding-agent 层面的语义,不是引擎事件)的区别:agent_end 只代表"这次 runAgentLoop 调用触发的模型-工具循环走完了",但 packages/coding-agent 可能在 agent_end 之后自动触发压缩重试、错误重试或处理排队的 follow-up 消息,直到真正没有任何后续动作才会对外宣告"settled"。这个区分在写扩展或状态展示 UI 时很重要------如果只监听 agent_end 就更新"空闲"状态指示,可能会在 pi 实际上还在自动重试的时候错误地显示为已完成。
三种控制流:steering、follow-up、abort
Agent 类维护两个独立的消息队列:
// packages/agent/src/agent.ts
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode; // "all" | "one-at-a-time"
enqueue(message: AgentMessage): void { this.messages.push(message); }
drain(): AgentMessage[] {
if (this.mode === "all") { const drained = this.messages.slice(); this.messages = []; return drained; }
const first = this.messages[0];
if (!first) return [];
this.messages = this.messages.slice(1);
return [first];
}
}
steer() 把消息放进 steeringQueue,followUp() 放进 followUpQueue。两者在 runLoop 里的消费时机完全不同:
- Steering(插话) :在
runLoop内层循环每一轮结束后 (turn_end触发之后、下一次调用模型之前)通过config.getSteeringMessages()拉取,一旦拉到就作为pendingMessages注入,并让hasMoreToolCalls || pendingMessages.length > 0继续为真,从而让内层循环再跑一轮------也就是说 steering 消息可以打断"agent 原本要结束了"的判断,把已经收尾的对话重新接上。这对应"模型还在处理时,用户又打了几个字纠正方向"的场景。 - Follow-up(排队) :只有当内层循环彻底退出 (没有更多工具调用、也没有 steering 消息)之后,才会通过
config.getFollowUpMessages()检查是否有排队消息,有的话让外层循环重新进入。这对应"用户在 agent 忙碌时又发了一条全新的问题,但明确希望等当前任务完全做完再处理"的场景。
两者都受 QueueMode 控制:"all" 一次性抽干队列全部消息、"one-at-a-time" 每次只抽一条(抽完这条、再跑一轮之后才会抽下一条)。Agent 构造函数里默认都是 "one-at-a-time"。
Abort(中止) 走的是完全不同的机制------标准 Web AbortController/AbortSignal:
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {
if (this.activeRun) throw new Error("Agent is already processing.");
const abortController = new AbortController();
// ...
this.activeRun = { promise, resolve: resolvePromise, abortController };
try {
await executor(abortController.signal);
} catch (error) {
await this.handleRunFailure(error, abortController.signal.aborted);
} finally {
this.finishRun();
}
}
abort(): void {
this.activeRun?.abortController.abort();
}
这个 signal 会被一路透传:streamFunction(model, llmContext, { ...config, signal }) 让底层 HTTP 请求可以被取消;tool.execute(toolCallId, params, signal, onUpdate) 让工具执行(比如 bash 里正在跑的子进程)也能响应中止。agent-loop.ts 里多处 if (signal?.aborted) 检查确保中止是"尽快生效"而不是"等当前步骤彻底跑完才生效"------例如 executeToolCallsSequential 在每个工具执行完之后会检查 signal?.aborted 并 break,不再发起下一个工具调用。
值得注意的是,一次 abort() 之后模型响应本身的 stopReason 会变成 "aborted",runLoop 里对此有专门分支:
if (message.stopReason === "error" || message.stopReason === "aborted") {
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}
中止不会抛异常打断循环,而是像一次正常收尾一样触发 turn_end 和 agent_end------这与第二篇讲的"工具异常也要转换成正常事件,而不是让异常传播"是同一种设计哲学:控制流的异常路径,最终都要收敛成正常的事件序列,这样上层 UI 和会话持久化逻辑不需要为每一种异常单独写特殊分支。
关键代码解读
完整的消息生命周期时序(含 steering)
时间 →
[turn 1]
turn_start
message_start (assistant, partial, stopReason=pending)
message_update × N (text_delta / toolcall_delta ...)
message_end (assistant, stopReason=toolUse)
tool_execution_start/update/end × M
message_start/end × M (toolResult)
turn_end { message, toolResults }
← 此刻 getSteeringMessages() 被调用,若用户中途插话,消息注入
[turn 2]
turn_start
message_start/end (assistant, 这次看到了 steering 消息带来的新上下文)
...
stopReason=stop,没有 toolCall → hasMoreToolCalls=false,且此时无 pendingMessages
← 内层循环退出,查 getFollowUpMessages()
若有排队的 follow-up 消息 → 回到外层循环开头,继续 [turn 3] ...
否则 → agent_end
消息落定前后的"占位"技巧
streamAssistantResponse 里的 context.messages.push(partialMessage)(在 start 事件时)和后续的 context.messages[context.messages.length - 1] = partialMessage(每次 *_delta 事件时原地替换)是一个值得注意的实现细节:上下文数组在流式过程中始终包含一条"尚未定稿"的消息 。这带来两个好处:其一,如果流式过程中途崩溃(比如网络错误提前触发 error 事件),context.messages 里已经有一条 stopReason 反映真实情况的消息,不需要额外补偿逻辑;其二,message_update 事件里透出的 message 字段就是这条 partial 消息的浅拷贝,UI 可以直接拿它当作"当前正在渲染的消息"数据源,不需要自己在本地维护一份增量拼接状态。
小结与思考题
本篇建立的分层是:内容块级流式状态 (AssistantMessageEvent 的 start/delta/end)被 streamAssistantResponse 折叠成消息级生命周期 (message_start/message_update/message_end);一条 assistant 消息 + 它引发的工具调用构成一轮(turn) ;若干轮构成一次 agent 运行 ;steering 在轮次间隙插入消息以影响下一轮,follow-up 在整次运行收尾后才追加新一轮,abort 通过 AbortSignal 贯穿到 HTTP 请求和工具执行的最内层,且中止本身被处理成正常的事件收尾而非异常。
思考题:
- 如果一个 UI 想要实现"逐字符打字机效果",应该订阅
message_update事件里的哪个字段?如果只想在消息完全生成后才渲染(比如生成 Markdown 后一次性渲染),应该只处理哪个事件? steer()和followUp()都是把消息塞进各自队列,为什么 steering 消息能够"打断"agent 即将结束的判断,而 follow-up 不能?这在runLoop的双层循环结构里具体是怎么实现的?- 如果一次 abort 发生在工具正在执行(比如
bash命令正在跑)而不是模型正在流式返回的时候,从agent-loop.ts的代码路径看,中止信号会经过哪些函数最终杀掉子进程?