PiAgent 04|模型适配器与流式响应#
把不同 Provider 的消息、thinking、工具调用和增量事件翻译成 Agent 能消费的统一协议
Agent Loop 表面上只需要一个 stream(),现实却远不止这一行。不同模型供应商在消息角色、工具格式、SSE 事件、thinking、缓存字段、上下文窗口和错误码上都有自己的方言。如果这些差异散落在 Loop、工具和 UI 代码中,任何换模型的需求都会变成全链路改造。
Pi 的模型层采用适配器思路:上层依赖统一的模型对象、上下文和事件流,Provider 差异集中在模型层消化。这样做的目标不是假装所有模型一样,而是把“共性控制流”和“供应商特性”分开。
1. 从三种对象理解模型边界#
Model:选择谁来回答#
模型对象至少需要 Provider、模型 ID、显示名称和能力信息。provider + id 通常构成稳定的模型身份;显示名称只用于 UI,不适合作为路由键。
Context:本次请求带什么#
上下文包含系统提示词、消息历史、工具定义以及可能的 thinking 或缓存选项。它是 Agent 内部状态到 Provider 请求的中间表示,不应直接等同于某一个供应商的 JSON。
Stream:回答如何回来#
流式结果不是字符串,而是一串有顺序的生命周期事件:消息开始、文本增量、thinking 增量、工具调用增量、usage、完成或错误。Agent 和 UI 依赖事件顺序,而不是只依赖最后那段文本。
2. 配置层:模型声明不等于模型可用#
Pi 的本地配置通常由 models.json、auth.json 和内置 Provider 定义共同构成:
{
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"api": "openai-completions",
"apiKey": "$DEEPSEEK_API_KEY",
"models": [
{ "id": "deepseek-chat", "name": "DeepSeek Chat" }
]
}
}
}几个字段的职责不同:
| 字段 | 作用 |
|---|---|
baseUrl | Provider 的接口根地址 |
api | 采用哪种协议适配器,例如 OpenAI 兼容协议 |
apiKey | 认证来源,生产环境应使用变量或认证存储 |
models | 该 Provider 暴露的模型 ID 和显示名称 |
ModelRuntime.create() 负责加载配置,getAvailable() 负责根据认证状态过滤模型。企业内网接入时,最先验证的不是 Agent 代码,而是三件事:网络是否可达、认证是否被读取、模型返回格式是否符合选定的 API 协议。
3. 适配器的两次翻译#
Pi Context / AgentMessage
│
▼
Provider Request Adapter
│ 角色、工具、thinking、缓存、参数
▼
具体模型 API
│
▼
Provider Stream Adapter
│ 文本增量、tool call、usage、错误
▼
Pi 的统一 AssistantMessageEvent第一步是请求翻译,第二步是响应翻译。只做第一步而不统一事件,上层仍然要为每个 Provider 编写一套流式处理器;只做第二步而不处理请求差异,则无法保证工具和历史消息能被正确发送。
4. 为什么流式响应要有事件协议#
for await (const event of stream(model, context)) {
switch (event.type) {
case "text_delta":
process.stdout.write(event.delta);
break;
case "thinking_delta":
// 交给可折叠的思考区域,而不是混进最终答案
break;
case "tool_call":
// 交给 Agent Loop 的工具执行阶段
break;
case "done":
// 记录 usage、stop reason 等收尾信息
break;
}
}如果 stream() 只返回最终字符串,会产生三个问题:
- UI 只能等待完整响应,无法做增量渲染。
- Agent 不能在模型完成整段文字后及时发现 tool call。
- 观测层无法区分等待模型、执行工具和重试的时间。
事件协议把响应拆成时间线,既服务于实时体验,也服务于控制流和诊断。
5. message_update 与内部事件的区别#
在 AgentSession 层,常见的文本事件形态是:
session.subscribe((event) => {
if (event.type !== "message_update") return;
const update = event.assistantMessageEvent;
if (update.type === "text_delta") {
renderText(update.delta);
} else if (update.type === "thinking_delta") {
renderThinking(update.delta);
}
});这里有两层 type:外层 message_update 表示 AgentSession 收到一条 assistant 消息更新;内层 text_delta 或 thinking_delta 表示更新的具体类型。只判断外层而直接读取 .delta 是不安全的,因为不同内层事件的字段和用途不同。
6. thinking、工具和能力差异不能靠字符串猜#
不同模型对 thinking 的支持方式可能不同:有的模型提供独立思考片段,有的只返回最终文本,有的使用特定预算参数。适配层应该通过模型能力和配置表达差异,而不是让上层通过模型名称做大量 if/else。
同样,工具调用也不是所有模型都以相同格式返回。统一后的 tool call 至少要保留名称、参数、调用 ID 和必要的原始信息,让 Loop 能验证、执行并配对结果。
模型层应保证:
- 同一个工具调用在增量阶段和最终阶段可以被正确合并。
- JSON 参数解析失败时产生结构化错误,而不是静默丢弃。
- Provider 的错误被归类为可重试、不可重试或用户输入错误。
- usage 和 stop reason 在流结束时仍然可供上层记录。
7. OpenAI 兼容接口的边界#
配置 api: "openai-completions" 只说明请求可以使用某种兼容协议,不代表后端完全等价于 OpenAI。仍然要验证:
- 是否支持
tools和正确的 tool call 结构。 - 是否支持流式响应,并按协议发送结束标记。
- thinking 字段是被接受、忽略还是报错。
- 上下文窗口和最大输出限制是否一致。
- 错误状态码和错误 JSON 是否足够让适配器分类。
企业内网模型能否接入 Pi,应该用一个最小诊断请求验证,而不是直接把完整 Coding Agent 接上去。先确认文本流,再确认工具调用,最后确认长上下文、取消和错误重试。
8. 流式调试的证据链#
出现“模型不输出”时,建议按顺序记录:
配置目录 → 选中的 provider/id → 请求是否发出
→ 首个 stream event → 最后一个 event
→ stop reason / usage → AgentSession 是否收到 message_update这样可以区分四类问题:
getAvailable()为空:配置或认证问题。- 请求已发出但没有事件:网络、协议或 Provider 响应问题。
- Provider 有事件但 Session 没有更新:适配器转换问题。
- Session 有更新但界面不动:订阅或事件投影问题。
常见误区
- 把
baseUrl能访问误认为工具调用和流式协议都兼容。 - 把 thinking 增量混入最终文本,导致用户看到内部过程。
- 只记录最终消息,不记录 stop reason 和 usage,无法解释重试与成本。
- 用模型显示名称作为判断条件,换模型后路由悄悄失效。
- 在 Agent Loop 里处理 Provider 特殊字段,破坏分层。
小结
模型适配层的真正价值是让上层依赖协议,而不是依赖某个供应商的 JSON 细节。请求侧把统一 Context 翻译成 Provider 方言,响应侧把流式结果翻译成统一事件;Agent Loop 消费这些事件,Session 和 UI 再把它们投影给外部。模型能力不同仍然存在,但差异被关在正确的边界里。
源码定位
- 模型与事件类型:
packages/ai/src/types.ts - Provider 与模型目录:
packages/ai/src/models.ts - 模型运行时:
packages/coding-agent/src/core/model-runtime.ts - 配置解析:
packages/coding-agent/src/core/config.ts - AgentSession 的流式事件转发:
packages/coding-agent/src/agent-session.ts