Agent CLI深水区 - 模型适配器与流式解析 · AI Agent
Agent CLI 深水区 06:模型适配器与流式解析
为什么模型适配器很关键
很多入门 demo 会在业务代码里直接写:
代码块收起展开
await client.messages.create(...)这样做短期最快,长期最糟。因为 Agent CLI 一旦成熟,会遇到:
- 不同模型供应商 message 格式不同。
- tool calling 格式不同。
- streaming event 格式不同。
- token usage 字段不同。
- 错误码不同。
- 有些模型支持 tool,有些不支持。
- 有些模型支持 thinking,有些不支持。
所以需要一层模型适配器:
flowchart LR
A[QueryEngine] --> B[ModelAdapter Interface]
B --> C[AnthropicAdapter]
B --> D[OpenAICompatibleAdapter]
B --> E[LocalModelAdapter]
B --> F[MockAdapter for Tests]
核心原则:
QueryEngine 只依赖统一的 ModelAdapter,不依赖任何具体 SDK。
适配器要屏蔽的差异:
| 差异 | 供应商常见表现 | 统一层要输出什么 |
|---|---|---|
| message 格式 | role/content、content blocks、tool result 格式不同 | Message[] |
| tool schema | JSON Schema 包装字段不同 | ToolSchema[] |
| tool call | 有的完整返回,有的流式分片 | UnifiedModelEvent / ToolCall |
| usage | input_tokens、prompt_tokens、最后事件才返回 | TokenUsage |
| stop reason | end_turn、tool_use、length 等命名不同 | 内部 StopReason |
| error | SDK 异常、HTTP 错误、流中断 | AgentError |
统一接口
代码块收起展开
// ModelAdapter = 模型供应商适配器统一接口。
// QueryEngine 只依赖这个接口,不直接依赖 OpenAI/Anthropic/本地模型 SDK。
export interface ModelAdapter {
name: string; // 适配器名字,例如 anthropic、openai-compatible、mock。
supportsTools: boolean; // 是否支持 tool calling。
supportsStreaming: boolean;// 是否支持流式输出。
// 非流式调用:一次性返回文本或工具调用。
complete(request: UnifiedModelRequest): Promise<UnifiedModelResponse>;
// 流式调用:逐个产出 text_delta/tool_call_delta 等事件。
stream?(
request: UnifiedModelRequest
): AsyncGenerator<UnifiedModelEvent>;
}统一请求:
代码块收起展开
// UnifiedModelRequest = Agent 内部统一的模型请求。
// 不同供应商字段名不同,但进入 adapter 前都用这个形状。
export type UnifiedModelRequest = {
model: string; // 模型名或逻辑别名。
messages: Message[]; // 当前上下文。
tools: ToolSchema[]; // 可用工具 schema。
temperature?: number; // 随机性参数。
maxOutputTokens?: number; // 最大输出 token。
abortSignal?: AbortSignal; // 支持用户取消/超时取消。
};统一响应:
代码块收起展开
// UnifiedModelResponse = 非流式模型响应。
// 要么是最终文本,要么是一次工具调用请求。
export type UnifiedModelResponse =
| {
type: "text";
text: string; // 模型回答文本。
usage?: TokenUsage; // token 用量。
}
| {
type: "tool_call";
id: string; // 工具调用 ID。
name: string; // 工具名。
input: unknown; // 工具参数,仍需 schema 校验。
usage?: TokenUsage; // token 用量。
};统一流式事件:
代码块收起展开
// UnifiedModelEvent = 流式模型事件。
// adapter 把供应商原始事件转换成这些统一事件。
export type UnifiedModelEvent =
| { type: "text_delta"; text: string } // 文本增量。
| { type: "tool_call_start"; id: string; name: string } // 开始一个工具调用。
| { type: "tool_call_delta"; id: string; partialInput: string } // 工具参数 JSON 片段。
| { type: "tool_call_done"; id: string; name: string; input: unknown } // 工具调用拼装完成。
| { type: "usage"; usage: TokenUsage } // token 用量。
| { type: "done" }; // 流结束。为什么要统一?
因为 QueryEngine 最怕被供应商格式污染。模型 API 变一次,你不应该改整个 Agent loop。
Message 转换
内部 message:
代码块收起展开
// Message = Agent 内部统一 message。
// tool message 是工具结果,不同供应商对它的格式要求差异很大。
export type Message =
| { role: "system" | "user" | "assistant"; content: string }
| { role: "tool"; toolCallId: string; content: string };不同供应商可能要求不同格式。适配器负责转换:
代码块收起展开
// toProviderMessages = 把内部 message 转成某个供应商接受的 message。
// 这是适配器职责,不应该散落在 QueryEngine 里。
function toProviderMessages(messages: Message[]) {
return messages.map(message => {
// 如果供应商没有原生 tool result 格式,可以退化成 user 文本。
if (message.role === "tool") {
return {
role: "user",
content: `Tool result (${message.toolCallId}):\n${message.content}`
};
}
// system/user/assistant 可以直接映射。
return {
role: message.role,
content: message.content
};
});
}注意:这里是示意。真实供应商如果有原生 tool result 格式,应优先使用原生格式。
Tool Schema 转换
内部 schema:
代码块收起展开
// ToolSchema = Agent 内部统一工具 schema。
type ToolSchema = {
name: string; // 工具名。
description: string; // 工具说明。
input_schema: JsonSchema; // 参数 schema。
};适配器转换:
代码块收起展开
// toProviderTools = 把内部 ToolSchema 转成供应商工具格式。
// 教学版假设供应商也叫 input_schema,真实供应商可能叫 parameters。
function toProviderTools(tools: ToolSchema[]) {
return tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.input_schema
}));
}有的供应商叫 parameters,有的叫 input_schema,有的包在 function 字段下。这些差异都不应该进入 QueryEngine。
流式解析为什么难
普通响应一次返回:
代码块收起展开
{
"type": "tool_call",
"name": "read_file",
"input": {"path":"src/main.ts"}
}流式响应可能分成很多片:
代码块收起展开
tool_call_start: read_file
tool_call_delta: {"path"
tool_call_delta: :"src/
tool_call_delta: main.ts"}
tool_call_done所以需要一个 accumulator。
流式工具调用的状态机:
stateDiagram-v2 [*] --> Idle Idle --> Collecting: tool_call_start(id,name) Collecting --> Collecting: tool_call_delta(id,text) Collecting --> Completed: tool_call_done(id) Collecting --> Failed: stream_error / malformed_delta Completed --> Parsed: JSON.parse(inputText) Parsed --> [*] Failed --> [*]
关键点:tool_call_delta 到达时不要解析 JSON,只累积;只有 tool_call_done 后才能把完整参数交给 JSON.parse。
ToolCallAccumulator
代码块收起展开
// ToolCallAccumulator = 流式工具调用参数拼装器。
// 模型可能把 {"path":"src/main.ts"} 分成很多 delta,需要累积后再 JSON.parse。
class ToolCallAccumulator {
// key 是 tool call id,value 保存该工具调用的名字和已收到的 input 文本。
private calls = new Map<string, {
id: string;
name: string;
inputText: string;
}>();
// start = 收到 tool_call_start 时初始化一条记录。
start(id: string, name: string) {
this.calls.set(id, { id, name, inputText: "" });
}
// append = 收到 tool_call_delta 时追加参数片段。
append(id: string, delta: string) {
const call = this.calls.get(id);
if (!call) {
// delta 到了但没有 start,说明流事件顺序异常或丢包。
throw new Error(`Unknown tool call stream id: ${id}`);
}
// 只拼字符串,不要中途 JSON.parse,因为中间片段不是完整 JSON。
call.inputText += delta;
}
// done = 收到 tool_call_done 后,把累积文本解析成 ToolCall。
done(id: string): ToolCall {
const call = this.calls.get(id);
if (!call) {
throw new Error(`Unknown tool call stream id: ${id}`);
}
// 完成后删除,避免内存泄漏。
this.calls.delete(id);
return {
id: call.id,
name: call.name,
// 这里才 parse JSON。真实系统要捕获 parse 错误并转成 MODEL_BAD_RESPONSE。
input: JSON.parse(call.inputText),
sessionId: "current" // 教学版写死,真实系统应传当前 sessionId。
};
}
}这里最容易出错的是 JSON 拼接:
- 中间 delta 不是完整 JSON。
- 可能有转义字符。
- 可能同时有多个 tool call。
- tool call 可能中途被模型取消。
streaming QueryEngine
代码块收起展开
// queryStreaming = 流式版 Agent 主循环。
// 它消费模型流事件,并在遇到工具调用时暂停模型输出、执行工具、再继续下一轮。
export async function* queryStreaming(userInput: string): AsyncGenerator<AgentEvent> {
// 构建初始上下文。
const messages = await buildContext(userInput);
// 用 accumulator 拼装流式 tool call。
const accumulator = new ToolCallAccumulator();
// model.stream 产出统一 UnifiedModelEvent。
for await (const event of model.stream!({
model: "default",
messages,
tools: getToolSchemas()
})) {
// 普通文本增量直接转成 AgentEvent。
if (event.type === "text_delta") {
yield { type: "text_delta", text: event.text };
}
// 工具调用开始:记录 id/name。
if (event.type === "tool_call_start") {
accumulator.start(event.id, event.name);
}
// 工具参数片段:追加到 accumulator。
if (event.type === "tool_call_delta") {
accumulator.append(event.id, event.partialInput);
}
// 工具调用完成:解析完整 input,开始执行工具。
if (event.type === "tool_call_done") {
const call = accumulator.done(event.id);
// 通知 UI 工具开始。
yield { type: "tool_start", call };
// 执行工具。内部负责权限、timeout、审计等。
const result = await executeToolCall(call);
// 通知 UI 工具完成。
yield { type: "tool_result", call, result };
// 把工具结果回填 messages,供下一轮模型调用使用。
messages.push({
role: "tool",
toolCallId: call.id,
content: result.content
});
// 工具结果回填后,需要开启下一轮模型调用。
// yield* 表示把另一个 generator 的事件继续向外转发。
yield* continueAfterTool(messages);
return;
}
}
}这里有个关键点:一次 stream 中如果出现 tool call,通常要结束当前模型输出,执行工具,再发起下一次模型请求。
usage 统计
不同模型返回 usage 的时机不一样:
- 普通响应:最后一起返回。
- streaming:可能在最后一个 event。
- 有些失败时没有 usage。
统一处理:
代码块收起展开
// normalizeUsage = 把不同供应商的 usage 字段归一化。
// 有的叫 input_tokens,有的叫 prompt_tokens;输出同理。
function normalizeUsage(raw: unknown): TokenUsage | undefined {
// 没有 usage 就返回 undefined,不要伪造数据。
if (!raw) return undefined;
// 教学版用 any 简化,真实系统要做 schema 校验。
const usage = raw as any;
return {
model: usage.model ?? "unknown", // 供应商没给就填 unknown。
inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
// 如果供应商没有 total,就自己相加。
totalTokens:
usage.total_tokens ??
(usage.input_tokens ?? usage.prompt_tokens ?? 0) +
(usage.output_tokens ?? usage.completion_tokens ?? 0)
};
}模型错误归一化
代码块收起展开
// normalizeModelError = 把供应商错误转成 AgentError。
// QueryEngine 不应该关心每个供应商的原始错误格式。
function normalizeModelError(error: unknown): AgentError {
// 先拿到可读 message。
const message = error instanceof Error ? error.message : String(error);
// 鉴权失败通常不能 fallback,必须修 key 或账号。
if (message.includes("401") || message.includes("Unauthorized")) {
return new AgentError("MODEL_AUTH_FAILED", message);
}
// 限流可以重试或切备用模型。
if (message.includes("rate limit") || message.includes("429")) {
return new AgentError("MODEL_RATE_LIMITED", message);
}
// 超时也可能 fallback 或重试。
if (message.includes("timeout")) {
return new AgentError("MODEL_TIMEOUT", message);
}
// 其他都归到 bad response。
return new AgentError("MODEL_BAD_RESPONSE", message);
}归一化后,QueryEngine 不需要知道每个供应商的错误格式。
错误归一化后的处理策略:
| 错误码 | 典型原因 | 是否 fallback | 上层动作 |
|---|---|---|---|
MODEL_AUTH_FAILED | API key 错、账号无权限 | 否 | 终止并提示配置问题 |
MODEL_RATE_LIMITED | 429、并发/配额限制 | 可以 | 等待、换备用模型、降级 |
MODEL_TIMEOUT | 网络慢、模型响应太久 | 可以 | 重试或切 backup |
MODEL_BAD_RESPONSE | 流格式异常、JSON 破碎 | 谨慎 | 记录原始片段,必要时让模型重试 |
MODEL_CONTEXT_TOO_LARGE | 输入超过窗口 | 否 | 回到 ContextBuilder 压缩 |
fallback 策略
代码块收起展开
// callWithFallback = 模型调用 fallback 策略。
// primary 失败后尝试 backup/small,但不是所有错误都该 fallback。
export async function callWithFallback(request: UnifiedModelRequest) {
// 候选模型/适配器按优先级排列。
const candidates = [
getAdapter("primary"),
getAdapter("backup"),
getAdapter("small")
];
let lastError: unknown;
for (const adapter of candidates) {
try {
// 尝试当前 adapter。
return await adapter.complete(request);
} catch (error) {
// 记录最后一个错误,最后全部失败时抛出。
lastError = error;
// 错误归一化后再判断是否继续 fallback。
const normalized = normalizeModelError(error);
// 鉴权失败通常说明配置错,换模型也大概率没用,直接停止。
if (normalized.code === "MODEL_AUTH_FAILED") break;
}
}
// 所有候选都失败后,抛最后一个错误的归一化结果。
throw normalizeModelError(lastError);
}不要所有错误都 fallback:
| 错误 | 是否 fallback |
|---|---|
| rate limit | 可以 |
| timeout | 可以 |
| 5xx | 可以 |
| auth failed | 通常不行 |
| bad request | 通常不行 |
流式生产契约
流式输出不能只理解成“边生成边打印”。在 Agent CLI 里,stream 还承担三类生产责任:
- 状态推进:模型可能先输出文本,再输出 tool call delta,再补 usage。
- 用户体验:用户看到的是增量文本,但系统内部必须保留结构化事件。
- 故障恢复:断流、半个 JSON、重复 delta、取消任务都要能解释。
一个成熟的模型适配器应该把供应商差异压成统一事件流:
代码块收起展开
// ModelStreamEvent = 模型流式输出的内部标准事件。
// QueryEngine 只处理这些事件,不直接依赖 OpenAI/Claude/本地模型的原始 chunk。
type ModelStreamEvent =
| {
type: "text_delta"; // 普通文本增量,用于终端实时展示。
text: string; // 本次新增文本,不是完整答案。
index: number; // 增量序号,用于检测乱序或重复。
}
| {
type: "tool_call_delta"; // 工具调用参数增量,通常要多片拼成完整 JSON。
callId: string; // 工具调用 ID,跨多个 delta 保持一致。
name?: string; // 工具名可能只在第一片出现。
argumentsDelta: string; // 参数 JSON 片段,不能直接执行。
}
| {
type: "stop"; // 模型结束事件。
reason: "end" | "tool_call" | "length" | "cancelled"; // 结束原因决定下一状态。
}
| {
type: "usage"; // token 用量,可能最后才到。
inputTokens: number; // 输入 token。
outputTokens: number; // 输出 token。
};常见流式事故要直接进入适配器测试:
| 事故 | 表现 | 正确处理 | 回归用例 |
|---|---|---|---|
| 半个 tool JSON | 参数只收到 {"path": | accumulator 保留 pending,不执行工具 | 输入不完整 delta,断言无 tool call |
| 重复 delta | 网络重放同一片文本 | 通过 index 或内容窗口去重 | 同一 index 两次,输出只出现一次 |
| stop reason 错误 | 明明有 tool call,却按 final answer 结束 | tool_call_delta 完整后优先进入 tool state | 混合 text/tool 事件,断言状态转移正确 |
| usage 缺失 | 供应商不返回 token | 标记 unknown,不伪造精确成本 | usage 为空时仍能落 trace |
| 用户取消 | Ctrl+C 或 UI stop | 取消模型请求,不再执行 pending tool | cancel 后 pending tool 不执行 |
| fallback 丢上下文 | primary 断流后 backup 从空 prompt 开始 | fallback 必须复用同一 request 和 traceId | primary timeout 后 backup 保留 messages |
生产里还要区分三种“可重试”:
| 层级 | 可以重试吗 | 原因 |
|---|---|---|
| 模型请求未产生任何事件 | 可以 | 没有外部可见副作用 |
| 已输出文本但未调用工具 | 谨慎 | 用户可能已经看到部分内容,要标记 continuation |
| 已产生 tool call 或工具已执行 | 不能盲重试 | 可能重复副作用,必须依赖 idempotency key |
最小验收标准:
- 每个 provider adapter 都能把原始 chunk 转成
ModelStreamEvent。 tool_call_delta必须先拼装、校验、审计,再交给 ToolExecutor。stop.reason="length"不能当成功,要进入上下文压缩或继续请求策略。- trace 同时记录原始 chunk 摘要和归一化事件,调试时能定位是 provider 问题还是 parser 问题。
- MockAdapter 支持流式事件队列,而不是只支持一次性完整 response。
MockAdapter:测试用
代码块收起展开
// MockAdapter = 测试用模型适配器。
// 不真的调模型,而是按顺序返回预设响应。
class MockAdapter implements ModelAdapter {
name = "mock";
supportsTools = true;
supportsStreaming = false;
// responses 是测试预设的模型输出队列。
constructor(private responses: UnifiedModelResponse[]) {}
async complete() {
// 每调用一次就取出一个响应。
const next = this.responses.shift();
if (!next) {
// 没有预设响应说明测试写错或 Agent 多调用了模型。
throw new Error("No mock response left.");
}
return next;
}
}用它测试 QueryEngine:
代码块收起展开
const model = new MockAdapter([
{
type: "tool_call", // 第一段模拟模型要调用工具,而不是直接回答。
id: "1", // 工具调用 ID,用于把后续 tool_result 对回这次调用。
name: "read_file", // 要触发的工具名,测试 QueryEngine 的工具路由。
input: { path: "README.md" } // 工具参数,测试 schema 校验和文件读取链路。
},
{
type: "text", // 第二段模拟模型读完工具结果后的最终回答。
text: "README says this project is an Agent CLI."
}
]);没有 MockAdapter,Agent loop 很难稳定测试。
模型适配层的读源码重点
看任何 Agent CLI 的模型层,重点问:
- 内部 message 是否统一?
- provider message 转换在哪里?
- tool schema 转换在哪里?
- streaming delta 如何拼装?
- usage 如何归一化?
- 错误如何分类?
- fallback 是否有边界?
- 是否有 mock model 方便测试?
这些才是“模型适配器”真正的工程价值。