Skip to content

04 · LLM 接缝:模型提供方也是一个插件

这一章拆解 dsh 如何与模型对话:一套提供方无关的"消息与流式词汇表",一个适配器注册表,以及 llm/stream 这个能包裹每一次模型调用的瀑布事件。

4.1 问题:模型 API 的巴别塔

如果你直接对接过模型 API,会立刻理解这一章要解决的问题。同一个"助手回答了一句话"这件事:

  • OpenAI 兼容协议:choices[0].message.content,流式走 SSE 的 data: {...} 行;
  • Anthropic:content: [{ type: 'text', text: ... }],流式事件名五花八门;
  • 各家还有自己的工具调用格式、思考内容(reasoning)字段、用量统计口径、错误码体系。

如果 Agent 循环的代码里到处是 if (provider === 'openai'),每接入一家新提供方都是一场灾难。dsh 的方案是双端翻译

agent-loop 只认识统一词汇表。所有提供方差异被封印在各自适配器里——而适配器,当然,是插件。

4.2 统一词汇表:Message 与 ContentBlock

先看消息。dsh 的消息是内容块(ContentBlock)的数组,块是带 type 标签的可扩展联合类型:

ts
// 四个与模型交互最相关的块类型(简化签名)
interface TextBlock { type: 'text'; text: string }
interface ReasoningBlock { type: 'reasoning'; text: string }   // 思考内容,与可见文本区分
interface ToolCallBlock { type: 'tool-call'; id: CallId; name: string; arguments: string }
interface ToolResultBlock { type: 'tool-result'; toolCallId: CallId; content: ContentBlock[]; isError?: boolean }

两个值得注意的设计决策:

① 工具参数全程是原始 JSON 字符串。 模型产出的是字符串,模型要收回的也是字符串——中途不解析、不重新序列化(官方适配器约定明确要求 block-end 时按原样 stringify)。原因:任何解析-再序列化都可能悄悄改动内容(数字精度、键顺序),而 tool/result 要靠 toolCallId 精确对应。校验发生在工具执行阶段,而不是词汇表阶段。

② 内容块可扩展。 ContentBlockMap 是声明合并的接口映射,插件可以添加自己的块类型(dsh 官方就扩展过 image 块)。框架代码对未知类型按 switch 兜底放行,而不是抛错。

4.3 流式词汇表:StreamChunk

模型输出是流式的,dsh 为此定义了块级增量的 chunk 协议:

ts
type StreamChunk =
  | { type: 'block-start'; index: number; blockType: ContentBlockType }  // 一个块开始
  | { type: 'text-delta'; index: number; text: string }                  // 文本增量
  | { type: 'reasoning-delta'; index: number; text: string }             // 思考增量
  | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
  | { type: 'block-end'; index: number; block: ContentBlock }            // 块完成,携带组装结果
  | { type: 'usage'; usage: TokenUsage }                                 // 用量(必须在 finish 之前)
  | { type: 'finish'; reason: FinishReason; replayState?: unknown }      // 结束原因

协议义务(官方文档对适配器作者的要求,demo 里会逐一实践):

  1. usage 必须在 finish 之前发出,之后不能再发任何东西;
  2. 块按首次出现顺序分配 index,交织的增量靠 index 归位;
  3. 失败只有两条合法路径:stream() 抛异常(传输/协议故障),或发出 finish { kind: 'error' | 'aborted' }(提供方带内故障);
  4. 必须遵守 options.signal 取消信号;
  5. finish.replayState 携带最小无损 JSON 投影,让后续调用能重建历史——这是"可回放"体系的基石之一。

finish 的原因同样是可扩展映射:stop(正常结束)、tool-calls(模型要求调工具)、max-tokens(截断)、aborted(被取消)、error(失败)。

4.4 接缝本体:LlmAdapter 与注册表

把提供方接入 dsh,只需要一个 LlmAdapter 子类:

ts
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'

class MyAdapter extends LlmAdapter {
  // 唯一必须实现的方法:把一次请求流式化为 chunk
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // options.messages:统一词汇表的消息
    // options.system / options.tools:系统提示词与工具 schema
    // options.signal:取消信号,必须遵守
    // 内部:翻译成提供方协议、发 HTTP、解析 SSE、翻译回 chunk
  }
}

GenerateOptions 是一个完全装配好的请求

字段含义
provider提供方路由键,选择适配器
model提供方模型 ID
messages有序对话(统一词汇表)
system?系统提示词文本
tools?工具 schema(JSON Schema)
temperature? / maxTokens? / stop?采样控制
signal?取消信号
sessionId? / purpose?会话身份与用途标注(路由/遥测用)

注册是插件的事:

ts
export const name = 'llm-myprovider'
export const inject = ['llm']

export function apply(ctx: Context) {
  ctx.llm.registerAdapter(['my-provider'], new MyAdapter())
  // 注册是 effect:插件卸载时自动撤销,HMR 安全
}

注册表的规矩(LlmRuntime):

  • 一个提供方路由只允许一个适配器,重复注册抛 LlmError(code DUPLICATE_ADAPTER);
  • 多路由注册要么全部成功要么全部失败,不留半注册状态;
  • 每次注册都有 disposer,可原子替换路由集合(replace())——为设置页切换模型服务而生;
  • 适配器还可以声明"可配置提供方目录"(settings 里能激活但当前未注册的路由)与"模型发现"(探测端点列出模型)——这就是 Web UI 设置页的数据来源。

4.5 拦截点:llm/stream 瀑布事件

适配器是"翻译官",但如果我想在翻译前后做点事呢?比如:记录每次调用的 token 消耗、测试时短路掉网络、注入调试头?dsh 的答案是 llm/stream 瀑布事件——它包裹每一次流式模型调用

ts
declare module '@deepseek-ai/cordis' {
  interface Events {
    // 每次流式调用都会经过这条瀑布链;调 next() 到达真实适配器
    'llm/stream'(this: LlmRuntime, options: GenerateOptions,
      next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
  }
}

你可以:

ts
// 统计所有模型调用
ctx.on('llm/stream', async function* (options, next) {
  const chunks: StreamChunk[] = []
  for await (const chunk of next()) { chunks.push(chunk); yield chunk }
  const usage = chunks.findLast((c) => c.type === 'usage')
  if (usage?.type === 'usage') totalTokens += usage.usage.outputTokens
})

或者干脆短路(测试环境的利器):

ts
// 不调 next(),用脚本化输出替代真实网络调用
ctx.on('llm/stream', async function* () {
  yield { type: 'block-start', index: 0, blockType: 'text' }
  yield { type: 'text-delta', index: 0, text: 'mock!' }
  yield { type: 'block-end', index: 0, block: { type: 'text', text: 'mock!' } }
  yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
  yield { type: 'finish', reason: { kind: 'stop' } }
})

一个关键约束:循环发起的请求是只读的

agent-loop 发出的请求带着 markAgentLoopRequest 标记并深度冻结(mutation 会抛错)。原因:请求内容必须是会话日志的纯函数(第 7 章会讲为什么)。所以 llm/stream 的监听器对循环请求只能读和包裹,不能改 options;想换配置,去 agent/request 瀑布(下一章)。

4.6 错误与重试:类型化的失败

提供方错误在适配器边界被归一化成一个 LlmFailure

ts
interface LlmFailure {
  message: string      // 人类可读
  code: string         // 稳定的、提供方无关的机器路由码:AUTH / RATE_LIMIT / NO_ADAPTER …
  status?: number      // HTTP 状态码
  providerRetryAfterMs?: number  // 提供方要求的等待时长
  requestId?: string   // 提供方请求 ID,用于对账
}

重试策略挂在适配器注册上(providerRetryPolicy),agent-loop 失败时通过 agent/request-error 瀑布事件把决策权交出去——默认监听器按策略重试,你的监听器可以返回 { kind: 'retry' } 接管恢复,或什么都不做让失败终止。"怎么重试"同样是一个可替换的插件决策。

4.7 本章小结与练习

这一章的心智模型:agent-loop 说一种语言(统一词汇表),每个提供方配一个翻译官(适配器),翻译官们统一登记在 ctx.llm 注册表里,每次通话都经过 llm/stream 瀑布链。

小练习:设计一个 MockAdapter 的流

不用看代码,凭 4.3 的协议义务,写出一个"回复固定文本"的 MockAdapter 应该依次 yield 哪些 chunk。写完对照 Demo 4 的答案。注意 usagefinish 的先后顺序——这是最容易错的地方。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写