开发 LLM 适配器
本教程共 32 篇 · 第 25 篇 · 更新于 2026-08-15 · 约 6 分钟阅读
本节目标:理解 LLM 适配器 seam,学会用 LlmAdapter + stream() 接入新模型提供方,并掌握 StreamChunk 分片协议。
适配器是什么
agent-loop 不调用 fetch,也不认识 DeepSeek chat-completions。它只构造统一的 GenerateOptions,消费统一的 StreamChunk。中间是 ctx.llm 注册表,底层是各个适配器——每个适配器负责把请求翻译成某家 API 的格式,再把响应翻译回 StreamChunk。
「接新模型」的全部工作因此收敛为:写一个适配器,注册路由,配置 agent-loop 使用它。
最小实现
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. 把 options.messages 转成提供方格式
// 2. 调用流式 API
// 3. 把响应转成 StreamChunk
}
}
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
ctx.llm.registerAdapter(config.providers, adapter)
}
stream() 是核心,返回异步生成器,逐片产出 StreamChunk。apiKey 走插件配置,用 !!js process.env.MY_API_KEY 注入,不硬编码、不落盘。
StreamChunk 协议
一次生成的完整分片序列:
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 文本块:start → delta → end
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'Hello' }
yield { type: 'text-delta', index: 0, text: ' world' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello world' } }
// 工具调用块
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{"command":"ls"}',
}
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call', id: CallId('call-123'),
name: 'bash', arguments: '{"command":"ls"}',
},
}
// usage 先于 finish
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }
// { kind: 'tool-calls' } 表示请求执行工具
}
协议有五条硬规则,违反任何一条都会让消费方解析出错:
- 每个
block-start必须有对应的block-end。 index从 0 开始递增,标识内容块顺序;同一个块的 delta 复用同一 index。tool-call-delta的argumentsDelta是原始 JSON 文本增量,可一次或分多次生成。finish必须是最后一个分片,之后不再输出任何内容。usage必须在finish之前。
Tip稳健做法:缓冲 finish/usage 直到提供方流的结束标记再统一 flush——有些提供方在末尾发送仅含 usage 的分片。
GenerateOptions 词汇
stream() 收到 GenerateOptions,包含:provider(选择适配器)、model(适配器拥有的模型 id)、messages(对话历史)、系统提示词、工具 schema、生成参数、停止序列和 signal(中止信号)。完整字段以 @deepseek-ai/dsh-llm 导出的 TypeScript 类型为准。
适配器必须把支持的字段映射到具体 API;不支持就抛带稳定 code 的 LlmError,不得静默丢弃——否则模型会拿到残缺的结果。
注册与模型解析
ctx.llm.registerAdapter(['my-provider'], adapter)
第一个参数是该适配器处理的提供方路由列表,GenerateOptions.provider 据此选择适配器。每个提供方路由只对应一个适配器,重复注册抛异常;多路由注册要么全部成功,要么全部失败。
适配器可覆写两个可选方法:
listModels():向选择器公布模型选项。resolveModel(provider, model, signal?):一次查询返回确切的提供方/模型身份,以及可选的 reasoning 元数据。推理强度是适配器映射到提供方请求的有序不透明 ID;保留适配器给出的权威可选列表(包括off),不要提升为核心枚举。异步查询必须响应signal。
服务会校验聚合结果:显式指定但不支持的推理强度,在调用 stream() 前就被拒绝。省略 reasoning 表示该模型没有可选的推理强度能力。
错误处理
传输与协议故障只有两条合法路径:从 stream() 抛出(用带稳定 code 的 LlmError),或以 finish { kind: 'error' | 'aborted' } 结束流。agent-loop 保留错误及其 code 供诊断与策略处理,不要依赖普通 Error 被自动转换。
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...attributionHeaders(),
},
body: JSON.stringify({ model: options.model, messages: options.messages }),
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
}
每个 HTTP 请求合并 attributionHeaders(),并传递 options.signal,让取消与资源释放完全停稳。code 一旦发布不要改动,上层按它做策略判断。
在 cordis.yml 中启用
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
providers:
- my-provider
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: my-provider
model: my-model-v1
my-llm 注册路由,agent-loop 的 agents.main 声明 provider 与 model,生成请求时就会命中新适配器。
参考实现
仓库自带两个完整适配器,包名分别是 @deepseek-ai/dsh-llm-deepseek 与 @deepseek-ai/dsh-llm-pi-ai(下文用简称 llm-deepseek / llm-pi-ai):
llm-deepseek:DeepSeek 官方适配器,OpenAI 兼容格式,直接 HTTP,SSE 用 eventsource-parser 分帧。llm-pi-ai:封装 pi-ai LLM 库(@earendil-works/pi-ai),API 格式与目录语义都不同。
历史回放受 adapter ownership 限制:只有历史消息与目标 provider 由同一个适配器实例拥有时,才传递 finish.replayState;跨适配器路由会剥离私有重放元数据。先读 deepseek 再读 pi-ai,最容易看出「契约不变、实现各异」的 seam 思想。
小结
- 适配器把
GenerateOptions翻译成厂商 API,把响应翻译回 StreamChunk。 stream()是核心:block-start/delta/end、tool-call、usage 先于 finish。- 协议五条硬规则,违反任何一条,消费方都会解析出错。
registerAdapter绑定 provider 路由;多路由注册全有或全无。- 错误只有两条合法路径:抛
LlmError,或以 error/aborted 结束流。