Agent 的核心数据结构不是字符串,而是消息列表。
原因很简单:模型每次请求都需要看到一个可解释的上下文,而工具调用也必须作为消息回到模型视野里。否则模型会不知道“刚才那个工具到底执行出了什么”。
Pi 的基础消息来自 pi-ai,可以简化成这样:
type Message = UserMessage | AssistantMessage | ToolResultMessage;
interface UserMessage {
role: "user";
content: string | ContentBlock[];
timestamp: number;
}
interface AssistantMessage {
role: "assistant";
content: Array<TextBlock | ThinkingBlock | ToolCallBlock>;
stopReason: "stop" | "length" | "toolUse" | "error" | "aborted";
usage: Usage;
timestamp: number;
}
interface ToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: ContentBlock[];
isError: boolean;
timestamp: number;
}
这里最重要的是:AssistantMessage.content 里不只有文本,还可能有 toolCall。Agent Loop 会扫描这些 tool call,然后执行对应工具。
如果后端只返回最终消息,用户会看到一个长时间空白的页面。Pi 的做法是把模型生成过程和工具执行过程都转成事件:
| 事件 | 说明 | UI 可以怎么用 |
|---|---|---|
agent_start |
一次运行开始 | 显示工作中状态 |
turn_start |
一轮模型请求开始 | 新建时间线节点 |
message_start |
一条消息开始 | 创建消息气泡 |
message_update |
助手消息流式更新 | 追加文本、显示 thinking、显示 tool call |
message_end |
一条消息结束 | 固化消息 |
tool_execution_start |
工具开始执行 | 展示工具名称和参数 |
tool_execution_update |
工具输出中间结果 | 展示 bash 实时输出这类信息 |
tool_execution_end |
工具结束 | 展示结果、错误或耗时 |
turn_end |
一轮结束 | 判断是否还有工具结果 |
agent_end |
整次运行结束 | 恢复输入框 |
stateDiagram-v2 [*] --> Idle Idle --> Streaming: prompt() Streaming --> ExecutingTools: assistant emits toolCall ExecutingTools --> Streaming: append toolResult and continue Streaming --> Idle: assistant stop Streaming --> Aborted: abort() ExecutingTools --> Aborted: abort() Aborted --> Idle: settle listeners
Pi 的 Agent 类会维护这些状态:
| 状态 | 作用 |
|---|---|
messages |
当前对话记录 |
tools |
当前可用工具 |
isStreaming |
是否正在处理 |
streamingMessage |
当前还没完成的助手消息 |
pendingToolCalls |
正在执行的工具调用 ID 集合 |
errorMessage |
最近一次错误 |
不要只把消息渲染到 UI,然后忘了保存结构化数据。后续的模型请求、会话恢复、压缩和分支都依赖结构化消息。
事件适合驱动 UI、日志、插件,不适合作为唯一存储。Pi 会在 message_end 时把消息写入会话,而不是只保存“事件日志”。
很多初学者会犯这个错误:工具执行完之后直接把结果显示给用户,却没有把结果追加到模型上下文。这样模型下一轮还是不知道工具输出。
正确做法是:
flowchart LR A["assistant: toolCall"] --> B["execute tool"] B --> C["toolResult message"] C --> D["append to context"] D --> E["next model request"]
把 examples/demos/02-tools.ts 里的工具结果文本改成错误消息,再观察下一轮模型会如何根据 isError 和内容生成最终回复。