教学版默认使用 MockModel,这样每个人不需要 API Key 也能跑通。但当你理解协议和 loop 后,可以用 OpenAI-compatible 接口做一次真实模型 smoke test,并进一步理解 provider adapter 应该放在哪一层。
Pi 官方 SDK 把 AgentSession 作为生命周期、消息历史、模型状态、压缩和事件流的管理对象;真实 provider 差异则被隔离在模型协议层。本节沿用这个边界:不要把 OpenAI-compatible 的字段直接塞进 Agent Loop,而是先转成教学版 AssistantMessage。
本仓库提供一个可选 Demo:
npm run demo:05
它不会读取仓库里的密钥文件,只从环境变量读取配置。 如果没有配置环境变量,脚本会打印用法并跳过,不会让普通 Demo 验证失败。
| 变量 | 说明 |
|---|---|
OPENAI_COMPATIBLE_BASE_URL |
例如 https://example.com/v1 |
OPENAI_COMPATIBLE_API_KEY |
模型 API Key,不要写进仓库 |
OPENAI_COMPATIBLE_MODEL |
模型名,默认 mimo-v2.5-pro |
OPENAI_COMPATIBLE_AUTH_HEADER |
api-key 或 authorization,默认 authorization |
如果服务商使用 OpenAI 标准鉴权,一般是:
OPENAI_COMPATIBLE_AUTH_HEADER=authorization
如果服务商文档要求 api-key header,则使用:
OPENAI_COMPATIBLE_AUTH_HEADER=api-key
Xiaomi MiMo 文档里的 OpenAI-compatible 示例使用 mimo-v2.5-pro 和 api-key header;FAQ 也说明 api-key 与 Authorization: Bearer 都可以使用。
OPENAI_COMPATIBLE_BASE_URL="https://example.com/v1" \
OPENAI_COMPATIBLE_API_KEY="你的 key" \
OPENAI_COMPATIBLE_MODEL="模型名" \
npm run demo:05
Demo 会做两件事:
/chat/completions 发送一个带 list_files 工具定义的请求。list_files,再把 tool result 发回模型获取最终回答。从工程角度看,它其实在演示一个小 adapter:
flowchart LR A["OpenAI-compatible response"] --> B["toTeachingAssistantMessage()"] B --> C["AssistantMessage"] C --> D["runAgentLoop contract"] E["ToolResultMessage"] --> F["OpenAI tool message"] F --> A
sequenceDiagram participant Demo as demo:05 participant LLM as OpenAI-compatible API participant Tool as local list_files Demo->>LLM: messages + tools LLM-->>Demo: assistant tool_call Demo->>Tool: execute list_files Tool-->>Demo: README.md / agent-notes.md Demo->>LLM: assistant tool_call + tool result LLM-->>Demo: final answer
| 原因 | 说明 |
|---|---|
| 可复现性 | MockModel 不需要网络和 key |
| 教学清晰度 | 初学者先理解 loop,不被 provider 细节打断 |
| 安全 | 默认项目不会要求保存密钥 |
| 边界稳定 | 真实模型输出存在不确定性,Demo 适合 smoke test |
真实项目里,你可以把 MockModel.complete() 替换成 OpenAI-compatible adapter。只要它最终返回教学版的 AssistantMessage,loop、工具和会话都不用重写。
OpenAI-compatible 的 assistant message 大致长这样:
type OpenAIAssistant = {
content?: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}>;
};
教学版需要的是:
type AssistantMessage = {
role: "assistant";
content: Array<TextContent | ToolCallContent>;
stopReason: "stop" | "toolUse" | "error" | "aborted";
};
转换规则:
| Provider 字段 | 教学版字段 | 注意 |
|---|---|---|
content |
{ type: "text", text } |
null 时不要生成空文本块 |
tool_calls[].id |
ToolCallContent.id |
后续 tool result 必须用这个 id 对齐 |
tool_calls[].function.name |
ToolCallContent.name |
必须和本地 ToolRegistry 名称一致 |
tool_calls[].function.arguments |
ToolCallContent.arguments |
先 JSON.parse,失败时转成错误工具结果 |
finish_reason === "tool_calls" |
stopReason: "toolUse" |
有些 provider 不完全一致,所以也要看 tool_calls.length |
Demo 5 里的核心函数就是:
function toTeachingAssistantMessage(message, finishReason): AssistantMessage {
const toolCalls = (message.tool_calls ?? []).map((call) => ({
type: "toolCall",
id: call.id,
name: call.function.name,
arguments: safeJsonParse(call.function.arguments),
}));
return {
role: "assistant",
content: [
...(message.content ? [{ type: "text", text: message.content }] : []),
...toolCalls,
],
stopReason: finishReason === "tool_calls" || toolCalls.length > 0 ? "toolUse" : "stop",
usage: { input: 0, output: 0, totalTokens: 0 },
timestamp: Date.now(),
};
}
这一步就是 Pi 的 pi-ai 思想缩影:provider 字段只在 adapter 里出现,Agent Loop 只看统一协议。
教学版工具执行完得到 ToolResultMessage:
{
role: "toolResult",
toolCallId: "call_123",
toolName: "list_files",
content: [{ type: "text", text: "README.md" }],
isError: false
}
OpenAI-compatible 下一轮要看到的是 tool message:
{
role: "tool",
tool_call_id: "call_123",
content: "README.md"
}
这里最重要的是 tool_call_id。如果丢了,模型就不知道这个工具结果对应哪一次调用。
真实产品通常不会等完整 response,而是消费 stream。你可以把流式 adapter 想成一个 builder:
flowchart TD
A["delta.content"] --> B["append text buffer"]
C["delta.tool_calls[i].function.name"] --> D["remember tool name"]
E["delta.tool_calls[i].function.arguments"] --> F["append argument buffer"]
B --> G["message_update"]
D --> G
F --> G
G --> H{"finish_reason?"}
H -->|"tool_calls"| I["emit AssistantMessage with toolCall blocks"]
H -->|"stop"| J["emit AssistantMessage with text blocks"]最容易错的是 tool arguments:流式返回时它可能是一段段 JSON 字符串,不能每次 delta 都 JSON.parse。正确做法是先按 tool_call.id/index 累积,finish 后再 parse。
| 场景 | Adapter 应该返回什么 |
|---|---|
| provider HTTP 401/429/500 | AssistantMessage.stopReason = "error",errorMessage 写明状态码和摘要 |
| tool arguments JSON 解析失败 | 生成 isError: true 的 tool result,让模型有机会修正 |
| 用户 abort 模型请求 | stopReason = "aborted",同时停止后续工具执行 |
| 网络中途断流 | 把已累积文本作为 partial 信息,最终仍发 message_end 或错误事件 |
真实 Pi 会把这些情况整理成稳定事件流,避免 UI、session 和工具状态卡在半截。
不要把 adapter 写成“直接操作会话”的大函数。比较稳的边界是:
| 层 | 应该做什么 |
|---|---|
| Adapter | provider request/response 与统一消息协议互转 |
| Agent Loop | 根据 AssistantMessage 决定是否执行工具 |
| API / AgentSession | 管 session、压缩、事件订阅、持久化 |
这也是 Pi 官方 SDK 强调 AgentSession 的原因:模型状态、消息历史、压缩和事件流是运行层职责,不应该泄漏到 provider adapter。
| 错误 | 修正 |
|---|---|
把 key 写入 package.json script |
用环境变量传入 |
base URL 多写 /chat/completions |
只填到 /v1,Demo 会拼接路径 |
provider 使用 api-key,但你用了 Bearer |
设置 OPENAI_COMPATIBLE_AUTH_HEADER=api-key |
| 模型没有返回 tool call | 先看 Demo 输出的 assistant content,再调整 prompt 或模型名 |
| tool arguments JSON 解析失败 | 把失败包装成 isError tool result,不要让进程直接崩 |
| 流式 tool arguments 每段都 parse | 先累积字符串,finish 后再 parse |
把 Demo 里的 list_files 改成 read_file,让真实模型先读取 agent-notes.md,再总结其中的 Agent Loop 定义。