深色模式
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 里会逐一实践):
usage必须在finish之前发出,之后不能再发任何东西;- 块按首次出现顺序分配
index,交织的增量靠 index 归位; - 失败只有两条合法路径:
stream()抛异常(传输/协议故障),或发出finish { kind: 'error' | 'aborted' }(提供方带内故障); - 必须遵守
options.signal取消信号; 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(codeDUPLICATE_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 的答案。注意 usage 和 finish 的先后顺序——这是最容易错的地方。