02 模型接口学习记录
本节目标
本节目标是理解 packages/ai 这一层的职责:它不是 agent loop,也不是产品会话层,而是统一模型协议和 provider 适配层。
学完后要能解释:
Context为什么是 agent 层和 provider 层的边界。streamSimple()为什么只是 provider 路由。- provider 如何把统一
Context转成厂商 payload。 - provider 如何把厂商原始流转成 pi 标准事件。
toolCall为什么是 assistant message 的内容。toolResult为什么必须回填到下一轮上下文。
我们怎么学的
- 先读
packages/ai/src/types.ts,确认模型层统一类型。 - 再读
packages/ai/src/stream.ts,确认streamSimple()的路由职责。 - 回到
packages/agent/src/agent-loop.ts,对照streamAssistantResponse()如何组装Context。 - 选 OpenAI Responses provider 粗看实现,因为真实运行输出里使用的是
api: "openai-responses"。 - 读
processResponsesStream(),看 OpenAIresponse.*事件如何翻译成 pi 标准事件。
普通 LLM 请求 vs Agent 请求
普通 LLM 请求可以先理解成:
用户消息 + 历史
-> 模型
-> 文本回答
Agent 请求多了工具和循环:
Context(systemPrompt, messages, tools)
-> provider
-> assistant text 或 toolCall
-> agent loop 执行 toolCall
-> toolResult 回填 messages
-> 下一轮 provider 请求
区别:
- 普通请求通常只需要模型直接回答。
- Agent 请求会把工具 schema 也交给模型。
- 模型返回
toolCall时,只是提出“我要调用这个工具”。 - 本地工具执行完成后,
toolResult必须放回下一轮Context.messages。 - 所以 agent 请求的关键不是“一次请求参数更复杂”,而是“请求、工具执行、上下文回填形成循环”。
flowchart LR
U1[用户输入] --> M1[普通 LLM 请求]
M1 --> A1[文本回答]
U2[用户输入] --> C[Agent Context]
C --> P[Provider]
P --> TC{assistant 输出}
TC -->|text| A2[文本回答]
TC -->|toolCall| T[本地工具执行]
T --> R[toolResult]
R --> C
核心类型
packages/ai/src/types.ts 里最重要的是这几个类型。
Context
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
Context 是 provider 层接收的统一输入。它只包含:
systemPromptmessagestools
这意味着 provider 不直接认识:
AgentSession- CLI/TUI/RPC
- session 文件
- steering/followUp 队列
- compaction/retry 产品逻辑
Message
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
模型层只认三种消息:
- 用户消息
- assistant 消息
- 工具结果消息
AssistantMessage
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
usage: Usage;
stopReason: StopReason;
}
assistant message 不是只有文本。它的内容可以是:
textthinkingtoolCall
这解释了为什么 tool calling 是模型输出的一部分,而不是 agent loop 凭空造出来的。
AssistantMessageEvent
provider 返回的是事件流,事件包括:
start
text_start / text_delta / text_end
thinking_start / thinking_delta / thinking_end
toolcall_start / toolcall_delta / toolcall_end
done / error
Context 是边界
我们的结论:
Context 是 agent 内部状态被整理后,交给 provider 的统一输入格式。
agent 层内部可以使用:
AgentMessage[]AgentTool[]AgentContext
但 provider 层只吃:
{
systemPrompt,
messages,
tools,
}
完整边界是:
Agent 内部结构
AgentMessage / AgentTool / AgentContext
|
| convertToLlm + create llmContext
v
pi-ai 统一协议
Context / Message / Tool
|
| provider.streamSimple()
v
真实 API payload
OpenAI / Anthropic / Google / Bedrock
可以用这张图记:
flowchart TD
A[Agent 内部状态] --> B[Context]
B --> C[Provider adapter]
C --> D[OpenAI/Anthropic/Google payload]
D --> E[厂商 stream events]
E --> F[Pi AssistantMessageEvent]
F --> G[Agent loop]
新手第一遍只需要抓住三个词:
Context:agent 整理好后交给 provider 的统一请求包。Provider:把统一请求翻译成厂商 API,又把厂商事件翻译回 pi 事件。AssistantMessageEvent:provider 回给 agent loop 的标准流式事件。
streamSimple 的职责
packages/ai/src/stream.ts 里的 streamSimple() 很薄:
export function streamSimple(model, context, options) {
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, withEnvApiKey(model, options));
}
它不懂 OpenAI、Anthropic、Google 的 payload。它只负责:
- 根据
model.api找 provider。 - 给 provider 补环境变量里的 API key。
- 调用 provider 的
streamSimple()。
所以 streamSimple() 是 provider 路由,不是模型执行逻辑本身。
为什么 provider 返回 stream event
provider 不直接返回完整 AssistantMessage,而是返回 AssistantMessageEventStream。
原因:
- 支持流式显示。
- 支持 thinking/text/toolCall 的结构化增量。
- agent loop 可以维护 partial assistant message。
- 最终仍然可以通过
response.result()获取完整 assistant message。
所以它同时提供两个能力:
for await (event of response) // 消费过程事件
await response.result() // 获取最终 AssistantMessage
toolCall 为什么有 start/delta/end
工具调用也是流式生成的,尤其是 JSON 参数可能分多段返回。
示意:
toolcall_start
toolcall_delta: {"path":
toolcall_delta: "README.md"
toolcall_delta: }
toolcall_end
在 toolcall_end 之前不能执行工具,因为:
- JSON 可能还没闭合。
- 参数还不能可靠解析。
- schema validation 还不能做。
- 过早执行会导致半截参数被执行。
所以 provider 负责组装完整 toolCall,agent loop 在完整 assistant message 出来后再执行工具。
更完整地说:
toolcall_start:工具调用块开始,通常能知道 tool call id 和工具名。toolcall_delta:参数正在增量生成,常见是 JSON 字符串片段。toolcall_end:工具调用块结束,参数才适合解析、校验和交给 agent loop。
sequenceDiagram
participant Provider
participant Loop as Agent loop
participant Tool
Provider-->>Loop: toolcall_start(read)
Provider-->>Loop: toolcall_delta({"path":)
Provider-->>Loop: toolcall_delta("README.md"})
Provider-->>Loop: toolcall_end
Loop->>Loop: validate complete arguments
Loop->>Tool: execute read({ path: "README.md" })
Tool-->>Loop: toolResult
这也是为什么看到 toolcall_delta 时不能急着执行工具:
- JSON 可能还没闭合。
- 参数可能还没通过 schema validation。
- 同一个 assistant message 可能还有其他内容块没有结束。
- agent loop 需要完整 assistant message 来稳定匹配 tool call 和 tool result。
toolResult 为什么要回填 Context
模型发出 tool call 后,它自己还不知道本地工具执行结果。
流程必须是:
assistant message: toolCall(read README.md)
-> pi 本地执行 read
-> toolResult message: README.md 内容
-> 放回 Context.messages
-> 下一次 streamAssistantResponse()
-> 模型基于 toolResult 继续回答
如果不把 ToolResultMessage 放回 Context.messages,下一轮模型调用就看不到工具输出。
因此 ToolResultMessage 不是日志,也不是纯 UI 展示数据。它是下一次模型调用的上下文输入。
回填图:
flowchart TD
A[assistant message: toolCall] --> B[agent loop 执行本地工具]
B --> C[ToolResultMessage]
C --> D[append 到 Context.messages]
D --> E[下一次 provider 请求]
E --> F[模型看到工具结果并继续回答]
OpenAI Responses provider
我们选 openai-responses 粗看,因为实际 JSON 输出里出现了:
"api": "openai-responses"
openai-responses.ts
这个文件负责:
- 创建 OpenAI client。
- 把
Context转成 OpenAI Responses params。 - 发起
client.responses.create(..., stream: true)。 - 创建一个空的统一
AssistantMessage output。 - 调用
processResponsesStream()。
关键形态:
const stream = new AssistantMessageEventStream();
const output: AssistantMessage = {
role: "assistant",
content: [],
...
};
let params = buildParams(model, context, options);
const { data: openaiStream } = await client.responses.create(params, requestOptions).withResponse();
stream.push({ type: "start", partial: output });
await processResponsesStream(openaiStream, output, stream, model);
stream.push({ type: "done", reason: output.stopReason, message: output });
processResponsesStream
这个函数维护同一个 output: AssistantMessage,边读 OpenAI 的原始事件,边更新 output.content,再发出 pi 标准事件。
事件映射:
response.output_item.added + reasoning
-> thinking_start
response.reasoning_text.delta
response.reasoning_summary_text.delta
-> thinking_delta
response.output_item.done + reasoning
-> thinking_end
response.output_item.added + message
-> text_start
response.output_text.delta
response.refusal.delta
-> text_delta
response.output_item.done + message
-> text_end
response.output_item.added + function_call
-> toolcall_start
response.function_call_arguments.delta
-> toolcall_delta
response.function_call_arguments.done
-> 补全 arguments
response.output_item.done + function_call
-> toolcall_end
映射关系也可以压成一张图:
flowchart LR
O[OpenAI response.* events] --> R[processResponsesStream]
R --> T[thinking_start/delta/end]
R --> X[text_start/delta/end]
R --> C[toolcall_start/delta/end]
T --> P[Pi AssistantMessageEvent]
X --> P
C --> P
最后:
response.completed
-> 更新 usage / cost / stopReason
streamOpenAIResponses()
-> done 或 error
回到 agent-loop
packages/agent/src/agent-loop.ts 的 streamAssistantResponse() 消费 provider 返回的统一事件。
关键流程:
const llmMessages = await config.convertToLlm(messages);
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
const response = await streamFunction(config.model, llmContext, ...);
for await (const event of response) {
// start -> message_start
// text/thinking/toolcall -> message_update
// done/error -> message_end
}
这说明:
- provider 层负责翻译。
- agent-loop 层负责消费事件并推进循环。
- 工具执行发生在完整 assistant message 之后。
本节最终结论
provider 层只负责“翻译”:
Context 翻译成厂商请求
厂商事件翻译成 pi 标准事件
agent-loop 层才负责“执行”:
发现完整 toolCall
执行工具
把 toolResult 回填上下文
决定是否继续下一轮
本节完成度
已完成:
- 理解
Context是 agent 和 provider 的边界。 - 理解
Message的三种角色。 - 理解 assistant message 可以包含
text/thinking/toolCall。 - 理解
AssistantMessageEventStream的作用。 - 理解
streamSimple()的 provider 路由职责。 - 理解 OpenAI Responses 的事件翻译路径。
- 理解
toolResult必须回填到上下文。
未展开,后续章节继续:
- 不同 provider 的 payload 兼容差异。
- TypeBox 工具 schema 如何转换成 provider tool schema。
validateToolArguments()如何做参数校验。- 默认工具如何注册到
Context.tools。 - 多 provider 切换和 cross-provider replay 细节。
下一节方向
下一节进入 03 Agent loop:读懂核心循环。
虽然第一节已经读过 runLoop(),但下一节会更细地看:
runLoop()的内外循环。executeToolCalls()的并行/串行执行。beforeToolCall和afterToolCallhook。shouldStopAfterTurn和prepareNextTurn。- steering/followUp 队列如何影响下一轮模型调用。
Source: 02-model-interface-notes.md