Agent CLI深水区 - 模型适配器与流式解析 · AI Agent

Agent CLI 深水区 06:模型适配器与流式解析

为什么模型适配器很关键

很多入门 demo 会在业务代码里直接写:

代码块TS · 1 行收起展开
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 schemaJSON Schema 包装字段不同ToolSchema[]
tool call有的完整返回,有的流式分片UnifiedModelEvent / ToolCall
usageinput_tokensprompt_tokens、最后事件才返回TokenUsage
stop reasonend_turntool_uselength 等命名不同内部 StopReason
errorSDK 异常、HTTP 错误、流中断AgentError

统一接口

代码块TS · 15 行收起展开
// 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>;
}

统一请求:

代码块TS · 10 行收起展开
// UnifiedModelRequest = Agent 内部统一的模型请求。
// 不同供应商字段名不同,但进入 adapter 前都用这个形状。
export type UnifiedModelRequest = {
  model: string;             // 模型名或逻辑别名。
  messages: Message[];       // 当前上下文。
  tools: ToolSchema[];       // 可用工具 schema。
  temperature?: number;      // 随机性参数。
  maxOutputTokens?: number;  // 最大输出 token。
  abortSignal?: AbortSignal; // 支持用户取消/超时取消。
};

统一响应:

代码块TS · 15 行收起展开
// 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 用量。
    };

统一流式事件:

代码块TS · 9 行收起展开
// 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:

代码块TS · 5 行收起展开
// Message = Agent 内部统一 message。
// tool message 是工具结果,不同供应商对它的格式要求差异很大。
export type Message =
  | { role: "system" | "user" | "assistant"; content: string }
  | { role: "tool"; toolCallId: string; content: string };

不同供应商可能要求不同格式。适配器负责转换:

代码块TS · 19 行收起展开
// 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:

代码块TS · 6 行收起展开
// ToolSchema = Agent 内部统一工具 schema。
type ToolSchema = {
  name: string;              // 工具名。
  description: string;       // 工具说明。
  input_schema: JsonSchema;  // 参数 schema。
};

适配器转换:

代码块TS · 9 行收起展开
// 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。

流式解析为什么难

普通响应一次返回:

代码块JSON · 5 行收起展开
{
  "type": "tool_call",
  "name": "read_file",
  "input": {"path":"src/main.ts"}
}

流式响应可能分成很多片:

代码块TEXT · 5 行收起展开
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

代码块TS · 46 行收起展开
// 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

代码块TS · 57 行收起展开
// 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。

统一处理:

代码块TS · 20 行收起展开
// 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)
  };
}

模型错误归一化

代码块TS · 24 行收起展开
// 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_FAILEDAPI key 错、账号无权限终止并提示配置问题
MODEL_RATE_LIMITED429、并发/配额限制可以等待、换备用模型、降级
MODEL_TIMEOUT网络慢、模型响应太久可以重试或切 backup
MODEL_BAD_RESPONSE流格式异常、JSON 破碎谨慎记录原始片段,必要时让模型重试
MODEL_CONTEXT_TOO_LARGE输入超过窗口回到 ContextBuilder 压缩

fallback 策略

代码块TS · 31 行收起展开
// 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 还承担三类生产责任:

  1. 状态推进:模型可能先输出文本,再输出 tool call delta,再补 usage。
  2. 用户体验:用户看到的是增量文本,但系统内部必须保留结构化事件。
  3. 故障恢复:断流、半个 JSON、重复 delta、取消任务都要能解释。

一个成熟的模型适配器应该把供应商差异压成统一事件流:

代码块TS · 23 行收起展开
// 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 toolcancel 后 pending tool 不执行
fallback 丢上下文primary 断流后 backup 从空 prompt 开始fallback 必须复用同一 request 和 traceIdprimary timeout 后 backup 保留 messages

生产里还要区分三种“可重试”:

层级可以重试吗原因
模型请求未产生任何事件可以没有外部可见副作用
已输出文本但未调用工具谨慎用户可能已经看到部分内容,要标记 continuation
已产生 tool call 或工具已执行不能盲重试可能重复副作用,必须依赖 idempotency key

最小验收标准:

  1. 每个 provider adapter 都能把原始 chunk 转成 ModelStreamEvent
  2. tool_call_delta 必须先拼装、校验、审计,再交给 ToolExecutor。
  3. stop.reason="length" 不能当成功,要进入上下文压缩或继续请求策略。
  4. trace 同时记录原始 chunk 摘要和归一化事件,调试时能定位是 provider 问题还是 parser 问题。
  5. MockAdapter 支持流式事件队列,而不是只支持一次性完整 response。

MockAdapter:测试用

代码块TS · 20 行收起展开
// 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:

代码块TS · 12 行收起展开
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 的模型层,重点问:

  1. 内部 message 是否统一?
  2. provider message 转换在哪里?
  3. tool schema 转换在哪里?
  4. streaming delta 如何拼装?
  5. usage 如何归一化?
  6. 错误如何分类?
  7. fallback 是否有边界?
  8. 是否有 mock model 方便测试?

这些才是“模型适配器”真正的工程价值。