Skip to content

Part III · 实现篇 / Ch 02

实现篇第2章 接通第一根管道——ModelClient ​

珊瑚色章鱼把两条流中的彩色片段分别整理成完整工具包,并将一个交给奶白色机器人。

本章导读: 你会写 src/types.ts 的 ModelClient 接口和 ModelEvent 类型,再在 src/model-client.ts 里把 Anthropic SDK 的事件翻译成内部事件。核心是一条纪律:业务代码永远不直接碰 SDK 的事件命名。

范围定了,小A打开编辑器,第一件事却是对着 types.ts 发呆。他问老Z:“模型接入怎么写?直接调 SDK 不就行了?”

“行,但你会后悔的。”老Z说,“先隔离模型协议。src/types.ts 的 ModelClient.stream() 产出内部 ModelEvent;src/model-client.ts 的 AnthropicModelClient 才依赖官方 SDK。业务代码永远不读 SDK 的事件命名。”

小A:“那中间这层,到底隔离了什么?”

先看内部消息契约 ​

老Z在 types.ts 里敲出一段:

ts
// src/types.ts,节选
export type ModelEvent =
  | { type: "text"; text: string }
  | { type: "tool_call"; call: ToolCall }
  | { type: "done" };

“文本、工具调用、完成被拆成三种,是因为流里它们到达的时间不同。”老Z说,“done 不携带‘回答一定有效’的结论,循环还要检查是否累计了文本或工具调用。有了这个小契约,业务代码就不需要读 SDK 的事件命名。”

小A追问:“那为什么不干脆设计成一个 message 事件,把文本、调用、完成一次带齐?”

“因为到达时间不同,下游能消费的时机也不同。合成一个事件,要么等整条流收完再发,那 stream() 就不配叫流式了;要么发半成品,让循环去猜哪一段能执行。三种事件对应三种时间上可独立消费的事实:text 一到就能追加显示;tool_call 一到表示这个调用的参数对象已经完整;整轮协议校验通过后,循环才会分派执行。done 一到代表适配层认为协议收尾;runAgent 不依赖这个事件判定业务完成。事件的粒度,由‘下游最早能做什么’决定,不由 provider 的事件长什么样决定。”

设计问题本产物的选择放弃的替代方案放弃的理由
事件粒度三种原子事件一条聚合 message聚合意味着等收齐,或发半成品
参数完整性tool_call 只发完整 input逐段增量参数下游无从判断何时可执行
done 语义只代表流收尾代表“回答有效”有效性要循环自行检查
业务可见协议内部三事件SDK 原始事件名换厂商时业务层零改动

老Z随手画了一张映射示意:

Anthropic SDK 原始事件                    内部 ModelEvent
─────────────────────────────            ──────────────
content_block_delta (text_delta) ───────► { type:"text" }
content_block_start (tool_use)
content_block_delta (input_json_delta)     (攒着,不产事件)
content_block_stop                 ──────► { type:"tool_call" }
message_delta / message_stop       ──────► { type:"done" }

“注意中间那块:input_json_delta 在映射表里没有对应行,它不产内部事件,只更新内部状态。SDK 事件是一段状态机的输入,内部事件是它解析之后的产出。”

消息映射不是类型断言 ​

小A盯着 toolInput 函数看了半天,问:“工具输入在 TypeScript 里是 unknown,为什么映射时还要检查?类型不都标好了吗?”

“因为模型和网络都能给出任意 JSON。”老Z打开 model-client.ts:

ts
function toolInput(input: unknown): Record<string, unknown> {
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
    throw new Error("tool input must be an object");
  }
  return input as Record<string, unknown>;
}

“第一行保留不可信类型;条件排除三类 JSON 非对象;最后的断言只发生在运行时检查之后。”老Z说,“它也不验证字段是否符合某个工具 schema,那是工具执行层的事。”

小A:“那这层到底防什么、防到什么程度?”

“防‘结构性的不可能’,不防‘业务上的不合法’。”老Z说,“工具输入必须是 JSON 对象,null、数组、字符串在结构上就不可能是工具参数,这一层只拒结构:{ path: 42 } 这种字段类型错的输入会放行,留给 execute 里的 stringField 去拒。两层防线各管一段,一层管‘它是个对象吗’,一层管‘字段对吗’。对照真实代码能看见层次:model-client.ts 的 toolInput 只做结构检查;tools.ts 的 read.execute 第一行是 const path = stringField(input, 'path')。每一层只答自己该答的问题,错位就变成重复校验或漏校验。”

“这里还藏着一个细节:toolInput 抛的 tool input must be an object 不满足 toModelEvents 里 startsWith("Anthropic") 的判断,会被包成 tool_use block N returned non-object input 的协议错误。错误消息带上 block index,定位时才知道是哪一段流出了问题。”

流式工具参数的状态 ​

小A换了个角度:“流式输出呢?工具参数是分好几段到的,我怎么知道什么时候凑齐了?”

“这就到了本章最绕的地方。”老Z指着 AnthropicModelClient.stream() 委托的 toModelEvents:

ts
// src/model-client.ts,toModelEvents(简化:省略按 index 取 activeTool 和错误包装)
if (event.type === "content_block_delta" && event.delta.type === "input_json_delta") {
  activeTool.json += event.delta.partial_json;
}
if (event.type === "content_block_stop" && activeTool) {
  input = JSON.parse(activeTool.json) as unknown;
}

“正常序列是 tool block start 创建 { index, id, name, startInput, json },同 index 的多个 delta 追加 JSON,同 index 的 block stop 才解析并产生内部 call。start 已带完整对象且没有 delta 时,直接使用该对象,零参数工具的 {} 就是这样有效的。任何较早时刻的 delta 字符串都可能不完整,不能拿去执行。”

小A:“那如果流中途出问题呢?”

“解析失败、非对象输入、未知 index 的 JSON delta,或流结束时仍有未关闭的工具 block,都会中断该响应;text block 的 stop 不会关闭工具 block。”

“多个工具同时来呢?”小A接着问。

“多个工具 block 可以交错到达,不能拿单个‘当前工具’变量去关联:工具调用按各自 stop 的顺序发出,但参数永远由同一 index 的 start/delta/stop 组成。”

“那一个工具 block 的生命周期,画出来长什么样?”小A问。

老Z画了一张状态图:

              content_block_start (tool_use)
                        │ 创建 ActiveTool{id,name,startInput,json:"",received:false}
                        ▼
              ┌───────────────────────┐
              │   未收到 delta 的块     │
              │  (startInput 权威)    │
              └───────────────────────┘
                        │
        input_json_delta(同 index)
                        │ json 追加 + received=true
                        ▼
              ┌───────────────────────┐
              │    已收到 delta 的块    │
              │     (json 权威)       │
              └───────────────────────┘
                        │
                        │ content_block_stop
                        ▼
        JSON.parse(json) → toolInput → yield tool_call → 从 Map 删除

“守住它的就两条规则:只有 block stop 才允许解析和产出,delta 永远只追加不解析。为什么不每次 delta 都试一次 JSON.parse?因为 partial_json 常常是还没闭合的半截,{"path":"a" 这种在任何时刻都解析不了。逐段试解析只会吐出一堆噪音错误,真正的坏 JSON 要等 stop 时才能确认。”

小A:“那交错到达的两个工具呢?”

同一流里两个工具 block 交错到达(model-client.test.ts 覆盖的场景)

start(1) → start(3) → delta(3) → delta(1) → stop(1) → stop(3)
   │          │          │          │           │          │
activeTools: {1}        {1,3}      {1,3}      {1,3}       {3}       {}
                                                                     │
产出事件:                                         tool_call(1)  tool_call(3)  done

“关键在最后一行:index 1 和 index 3 各自攒自己的 JSON,互不干扰。换成单个‘当前工具’变量,第二个 start 会把第一个覆盖掉,第一个工具的参数就丢了。toModelEvents 因此要用 Map<index, ActiveTool> 而不是单个变量,下一节细看。”

toModelEvents 是一台小型状态机 ​

小A:“那真实的 toModelEvents 到底怎么处理这些?我照着概念写个 activeTool 变量够吗?”

“不够。”老Z打开真实的 model-client.ts,指着中间那段:“它维护的是一个 Map<index, ActiveTool>,因为多个工具 block 可能交错到达:”

ts
// examples/mini-agent/src/model-client.ts,节选
const activeTools = new Map<number, ActiveTool>();
let stopReason: Anthropic.StopReason | null | undefined;
let toolCallCount = 0;
let receivedMessageStop = false;

“每个开着的 block 都按它的 index 存着。你看完整的 ActiveTool 字段:”

ts
type ActiveTool = {
  id: string;
  name: string;
  startInput: unknown;
  json: string;
  receivedInputJsonDelta: boolean;
};

小A指着 receivedInputJsonDelta 问:“这个布尔是干嘛的?”

“这是最容易漏的细节。”老Z说,“content_block_start 事件里的 input 可能已经带了完整参数(比如零参数工具 {}),也可能只是开头。receivedInputJsonDelta 记录‘这次有没有收到过增量 JSON’:收到过,才需要用拼接的 json 重新 JSON.parse;没收到过,直接采用 startInput。你看这段:”

ts
// examples/mini-agent/src/model-client.ts,节选
let input: unknown = activeTool.startInput;
if (activeTool.receivedInputJsonDelta) {
  try {
    input = JSON.parse(activeTool.json) as unknown;
  } catch {
    throw protocolError(
      `tool_use block ${event.index} had invalid input_json_delta JSON`,
    );
  }
}

小A接过话头:“所以收到过 delta 的,json 说了算?”

“对。一旦有 delta,json 就是权威,拼不完整或解析失败都直接抛错。”

小A:“前面说的那几种坏情况,真代码里都怎么收场?”

老Z把错误路径数了一遍:“input_json_delta 到达时没有对应的开着的 block、重复的 tool_use start、block stop 时 JSON 解析失败、流结束时还有未关闭的 block、没收到 message_stop、stop reason 不支持或与实际调用数矛盾。每一种都 throw protocolError,不会产出 done。”

ts
// examples/mini-agent/src/model-client.ts,节选
if (activeTools.size > 0) {
  throw protocolError(
    `stream ended with unclosed tool_use block(s): ${[...activeTools.keys()].join(", ")}`,
  );
}
if (!receivedMessageStop) {
  throw protocolError("stream ended before message_stop");
}

小A瞄了一眼:“这些错误全带 Anthropic stream protocol error: 前缀?”

“这样调用方一眼就能分辨协议问题和业务问题。”老Z说,“这就是 fail loud:流状态不完整,宁可整个响应失败,也不把半截参数当完整调用交出去。”

老Z把这张状态机的错误路径收成一张表,全部带 Anthropic stream protocol error: 前缀:

触发错误消息(前缀略)为什么不是静默跳过
input_json_delta 无对应开着的 blockinput_json_delta arrived without an open tool_use block at index N顺序已损坏,静默会串流
重复 tool_use startduplicate tool_use start for block N同一 index 两个 start,参数归属不清
block stop 时 JSON 解析失败tool_use block N had invalid input_json_delta JSON半截参数不能当完整调用
流结束仍有未关闭 blockstream ended with unclosed tool_use block(s): …缺结果的调用让模型悬空
没收到 message_stopstream ended before message_stop无法确认本轮完整结束
缺 stop reasonmessage_delta did not provide a stop_reason无法判断该不该继续循环

“对照 model-client.test.ts,上面每一行都有离线测试盯着,协议错误不靠人工观察,靠测试红。”

小A有点不服气:“单变量也不是完全不能用吧?平时又碰不到交错。”

方案关联依据交错到达时
单个 activeTool 变量“当前正在拼的工具”第二个 start 覆盖第一个,参数丢失
Map<index, ActiveTool>provider 给的 content block index各 index 独立累积,互不干扰

“单变量不是每次都错,只有交错时才错,这正是它诱人的地方。但交错是 provider 允许的正常情况,associates simultaneous tool JSON with their content-block indexes 这个测试专门盯着它。”

停止原因是提交前提 ​

小A在日志里看到 stop_reason 字段,随口说:“这个不就是个展示信息吗?”

“不是。”老Z的语气严肃起来,“message_delta.delta.stop_reason 不是展示信息。此适配器目前只接受两种完整语义:end_turn,必须没有已完成的工具调用;tool_use,必须至少有一个已完成的工具调用。”

“max_tokens、pause_turn、refusal、stop_sequence、缺失的 stop reason,以及上述两种 reason 与实际工具调用不一致,都会抛出带 provider 协议上下文的错误,不会产出 done。”老Z说,“这样截断的文本就不会被下游循环误当成完整回答。message_stop 或迭代器自然结束时若工具 block 尚未关闭,也同样报错。”

stop_reason允许的条件语义循环的动作
end_turn无已完成工具调用模型回答完整提交文本,返回 complete
tool_use至少一个已完成调用模型请求工具分派工具,下一轮继续
max_tokens不支持输出被截断抛错,不产 done
pause_turn不支持模型保留轮次抛错,不产 done
refusal不支持模型拒绝抛错,不产 done
stop_sequence不支持自定义停止词抛错,不产 done
缺失不支持协议不完整抛错,不产 done

小A:“max_tokens 在真实产品里很常见吧?一刀切拒绝是不是太保守了?”

“因为 max_tokens 意味着输出被切断:文本说到一半没了,或者工具参数拼到一半。把它当完整回答提交是误导;把它当工具请求分派,参数可能根本不可执行。当前产物宁可整轮失败,也不在‘截断’上假装‘完整’。将来要支持,得先设计‘半截文本要不要保留、要不要自动重试、重试几次’这些语义,那是新需求,不是这一章的默认值。”

正常、中止与证据边界 ​

小A想起另一件事:“那取消呢?用户点了停止,我该怎么办?”

“中止路径有两个 checkpoint。”老Z打开 agent.ts:

ts
// examples/mini-agent/src/agent.ts,节选
for await (const event of options.client.stream(request)) {
  if (aborted(options.signal)) {
    return { messages, stopReason: "aborted" };
  }
  // 收集本轮事件
}
if (aborted(options.signal)) {
  return { messages, stopReason: "aborted" };
}

“第一个判断防止在已知取消后继续吸收事件;第二个判断位于流完成与 assistant commit 之间。这两个检查点不提供任意时刻的强制抢占:若底层异步迭代器永不返回且不响应 signal,宿主仍无法越过正在等待的 for await。”

小A:“所以取消是……协作式的?”

“对。AbortSignal 会传给 SDK,但客户端若不及时响应 signal,循环只能在事件返回或流结束后的 checkpoint 观察取消。已经收到的部分文本不会被提交为完整 assistant。”

老Z把整条链路的验证边界列了一张表:

事实本轮如何验证未验证
纯映射拒绝 null inputfake 单元测试SDK 服务端接收
工具按 index 累积、start input 和 block 闭合构造的离线 SDK 事件单元测试真实 Provider 流顺序
不支持/缺失 stop reason 不会给出 done构造 max_tokens、缺 reason 等离线测试Provider 对异常响应的实际投递
fake 回路工作npm test真实 API、费用、限流
流结束竞态不提交 assistant非协作 fake stream 回归测试SDK 的取消延迟上限

“这张表的两列要一起看:‘本轮如何验证’是承诺,‘未验证’是还没兑现的部分。”

“那取消在循环里到底落在哪些点?”小A问。

老Z把 runAgent 一个 turn 的检查点画成图:

runAgent 单个 turn 的取消检查点(agent.ts)

turn 开始
  │  ① aborted?(本轮循环前)──────────是──► return aborted
  ▼
for await (client.stream(...))
  │  ② aborted?(每个事件后)────────────是──► return aborted
  ▼
流结束
  │  ③ aborted?(assistant 提交前)───────是──► return aborted(竞态不提交)
  ▼
提交 assistant(text=="" && calls==0 时抛错)
  ▼
for call of calls
  │  ④ aborted?(每个工具执行前)──────────是──► 记录 aborted before execution
  │  ⑤ aborted?(审批之后)
  ▼
tool.execute(call.input, signal)
  │  ⑥ aborted?(工具执行后)──────────────是──► 记录后续未执行
  ▼
下一个 call / 下一轮

“注意 ③ 这道闸:流已经自然结束,但 signal 恰好在最后一段事件到达时被置位。不检查这一下,半截文本就会被当完整 assistant 提交。cancellation.test.ts 的 abort after a non-cooperative stream completes prevents assistant commit 就是专门为这道闸写的。”

小结 ​

接通模型流的关键,是在厂商协议和内部事件之间留一道适配缝。ModelClient 只暴露一个 stream() 方法,接收 system、messages、tools 和可选 signal,产出三种内部事件:text、tool_call、done。循环只消费这三种事件,永远不必知道背后是 Anthropic、OpenAI 还是别的什么,适配层的全部价值就在这一层薄薄的隔离上。事件的粒度不是随便定的:text 一到就能显示,tool_call 一到就代表一个完整可执行的调用,done 只承诺协议收尾、不承诺回答有效,后者要循环自己检查文本和调用数。

toModelEvents 是一台小状态机,它的存在理由是那几条交叉路。工具参数按 provider 的 content block index 用 Map 累积,receivedInputJsonDelta 区分“参数跟着 start 一次给全”和“靠 delta 拼出来”两种来源;只有 block stop 才解析并产出,任何时刻的半截 JSON 都不能拿去执行。协议错误统一带 Anthropic stream protocol error: 前缀,宁可靠测试红、宁可整轮失败,也不把半截参数当完整调用交出去。stop reason 同样严格:end_turn 和 tool_use 两种完整语义之外,max_tokens、refusal 都直接抛错,截断的文本不该被当成完整回答。

取消是协作式的。AbortSignal 会传给 SDK,但循环只在几个固定检查点上观察取消:每轮流开始前、流进行中、流结束与 assistant 提交之间、每个工具执行前后。流结束后的那道闸尤其重要:signal 恰好在最后一段事件时到达,不做检查就会把半截文本提交成完整 assistant。每个检查点的取舍都是宁可少提交、不可多提交,signal 的传播路径因此可预测,调试时能定位中断发生在哪一道闸。

但这道缝不会自动适配所有厂商。“adapter 能跑”只证明当前这家厂商的事件格式被正确翻译了,换一家就要重写消息角色转换、partial JSON 拼接、停止语义映射,每一项都得用 fake stream 配合真实服务的受控测试来验证。当前产物的边界要诚实:真实 Anthropic 端到端流尚未验证,超时、重试、用量统计也没实现。把“adapter 接好了”当成“全厂商可用”,是这一章最容易踩的坑。

阶段验收 ​

运行 npm test 和 npm run typecheck;不要为此设置真实凭据。