DeepSeek Harness 自定义 LLM 适配器怎么写:LlmAdapter、StreamChunk 协议与 registerAdapter 注册
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
如果你要给 DeepSeek Harness 接入一个新的模型提供方(自己的网关、自建推理服务或其他厂商的 API),需要写一个 LLM 适配器插件:它继承LlmAdapter,把 Harness 的提供方无关请求转换成具体提供方的 API 调用,再把响应流转换回 Harness 的StreamChunk分片,最后通过ctx.llm.registerAdapter()注册到llm服务上。完成后,组合(composition)里的 agent-loop 就能像调用内置 DeepSeek 路由一样调用你的提供方。
本文的主路径是:读懂契约 → 写适配器类 → 遵守 StreamChunk 协议 → 处理错误 → 注册并在cordis.yml中挂载 → 验证路由可用。契约的权威定义在 packages/llm/llm(@deepseek-ai/dsh-llm)及其类型源 packages/llm/llm/src/types.ts,操作指南见 LLM adapters,协议详解见 LLM Streaming 子系统文档。
契约要点:一个必须实现的方法
LlmAdapter是抽象类,源码中标注stream()是唯一的必需方法:
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;其余方法都是可选覆写,按需添加:
| 方法 | 用途 |
|---|---|
resolveModel(provider, model, signal?) | 一次查询返回精确的 provider/model 身份,加可选的context(上下文容量)、reasoning(推理强度)元数据 |
listModels(provider) | 向模型选择器公布可选模型。建议性目录(advisory):适配器可以接受未列出的 model id,消费方不得把"未列出"当作请求拒绝 |
providerInfo(provider) | 返回路由展示元数据,id必须等于provider |
providerRetryPolicy(provider) | 返回路由级重试策略;返回undefined则用默认策略 |
imageRequestPricing(provider, model) | 提供方对请求图片计费时声明定价;必须同步、无 I/O |
GenerateOptions包含 provider、model、适配器拥有的推理强度 id(reasoningEffort)、对话历史messages、系统提示词system、工具 schematools、生成参数、停止序列stop和AbortSignal。注意一个硬限制:GenerateOptions的采样参数只有temperature/maxTokens/stop,没有top_p、tool_choice或 penalty 字段(见 dsh-llm README 的已知限制)。把支持的字段映射到提供方 API;提供方无法支持的字段,应抛出带稳定 code 的LlmError,而不是静默丢弃。
关于resolveModel():服务会在stream()之前校验聚合结果,并拒绝显式指定但不受支持的推理强度;如果模型元数据里省略reasoning,表示该模型没有可选的推理强度能力。推理元数据包含有序的不透明 id、展示名称和可选的配置默认值——保留适配器自己的权威列表(包括上游能力 API 返回的off),不要把这些值提升为核心枚举。
最小适配器实现
以下是 LLM adapters 指南中的最小实现,代码中的my-llm-adapter、my-provider、my-model-v1是文档示例名,替换为你自己的插件名、提供方路由和模型 id:
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. Convert options.messages to the provider format. // 2. Call the streaming API. // 3. Convert the response into StreamChunk values. } } 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) }一个 Cordis 插件需要导出name、Configschema、inject和apply;inject: ['llm']声明对llm服务的依赖,apply里完成注册。配置校验用@deepseek-ai/schemastery,它属于运行时依赖(见 adding-a-package 手册对 package.json 的要求)。
StreamChunk 协议:分片的顺序与形状
stream()按以下协议产出分片。下面是指南中的文档示例,展示一次含文本块和工具调用块的完整流:
import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' async function* exampleChunks(): AsyncIterable<StreamChunk> { // 1. Start each content block with block-start. yield { type: 'block-start', index: 0, blockType: 'text' } // 2. Stream text through text-delta. yield { type: 'text-delta', index: 0, text: 'Hello' } yield { type: 'text-delta', index: 0, text: ' world' } // 3. End each content block with block-end and the complete block. yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello world' }, } // 4. Tool-call block. yield { type: 'block-start', index: 1, blockType: 'tool-call' } yield { type: 'tool-call-delta', index: 1, id: ToolCallId('call-123'), name: 'bash', argumentsDelta: '{"command":"ls"}', } yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('call-123'), name: 'bash', arguments: '{"command":"ls"}', }, } // 5. Token usage. yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } // 6. Finish reason. yield { type: 'finish', reason: { kind: 'stop' } } // Alternatively, { kind: 'tool-calls' } requests tool execution. }分片类型的完整联合(含reasoning-delta)见 types.ts,协议说明见子系统文档。必须遵守的关键规则:
- 每个
block-start都有对应的block-end;block-end携带组装好的完整ContentBlock,消费方不需要自己重新拼接增量。 index从 0 开始递增,标识内容块的顺序。tool-call-delta的argumentsDelta携带原始 JSON 文本增量,可以一次给完,也可以分多个分片;工具参数从头到尾保持原始 JSON 字符串。usage必须在finish之前发出;finish是最后一个分片,其后不能再有任何内容。建议把这两者推迟到提供方的流结束标记处发出,避免尾部的 usage 分片破坏顺序。usage的计数字段是互斥的:inputTokens只含未缓存输入,缓存命中单独报告;提供方若把缓存折叠进单一 prompt 总数,需要在适配器里拆出来。
失败时你有两条合法路径:要么从stream()中抛出异常(传输/协议错误),要么以finish { kind: 'error' | 'aborted', failure }结束流(提供方带内错误、无法中途抛出的场景)。两条路径都收敛到同一个LlmFailure结构,消费者按code路由,从不依赖报错文本。
错误处理:LlmError、attributionHeaders 与中止信号
适配器把传输和协议故障抛出带稳定 code 的LlmError;agent loop 会保留该错误和 code 用于诊断与策略,不会自动转换普通Error。另外,每个 provider HTTP 请求都必须合并attributionHeaders()并转发options.signal。指南给出的示例:
import { attributionHeaders, LlmAdapter, LlmError, type GenerateOptions, type StreamChunk, } from '@deepseek-ai/dsh-llm' class HttpAdapter extends LlmAdapter { constructor(private readonly endpoint: string) { super() } async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> { 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') } // A real adapter parses the response and emits the complete chunk sequence. yield { type: 'finish', reason: { kind: 'stop' } } } }attributionHeaders()只映射标准的User-Agent头,其内容是公开产品事实,不含密钥、路径或会话 id。LlmError的构造参数是(message, code, options?),其中status必须是 100–599 的整数,providerRetryAfterMs必须是正数(见 LlmError 实现)。
registerAdapter:注册与路由规则
注册调用:
ctx.llm.registerAdapter(['my-provider'], adapter)第一个参数是该适配器处理的提供方路由列表。运行时的路由规则(见 LlmRuntime 实现):
GenerateOptions.provider按路由选择已注册的适配器;GenerateOptions.model直接传给适配器,无需在生命周期启动时注册。- 路由重复会失败:任一 provider 已有适配器时抛出
DUPLICATE_ADAPTER,且是 all-or-nothing(整个注册回滚)。 - 空数组不合法:初始注册至少需要一个 provider,否则抛
INVALID_ADAPTER。 - 返回值是 disposer,附带
replace(providers):用同一适配器实例原子地替换路由集,候选集先整体校验,冲突时当前路由不受影响。注册随 fiber 销毁。
在 cordis.yml 中挂载并验证
指南给出的组合示例(!!js process.env.MY_API_KEY是文档示例写法,读取你启动环境中的环境变量MY_API_KEY,按需替换成你自己的凭据来源):
- 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验证挂载是否成功,dsh-llm README 给出两个文档化的检查点:
- 注册后:
ctx.llm.listProviders()按注册顺序报告已注册的路由——你的my-provider应当出现。 - 发一次真实流式请求,观察分片序列。README 中的消费示例(文档示例,值来自内置 DeepSeek 路由;自定义适配器替换为你注册的 provider 路由和模型 id):
for await (const chunk of ctx.llm.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [createUserMessage({ content: [{ type: 'text', text: 'Hello' }] })], })) { // chunks: block-start, text-delta, ..., usage, finish }流总是以恰好一个终止finish分片结束:正常完成为{ kind: 'stop' }(或{ kind: 'tool-calls' }请求执行工具),失败为{ kind: 'error', failure },取消为{ kind: 'aborted', failure }。
排错时按稳定 code 判断,而不是报错文本:
| 现象 | code |
|---|---|
| 请求指向未注册的 provider | NO_ADAPTER |
| 注册时路由已被占用 | DUPLICATE_ADAPTER |
| 请求中没有任何凭据 | MISSING_CREDENTIAL |
| 凭据格式非法 | INVALID_CREDENTIAL(报错会指明该修哪条引用,且不含密钥的任何部分) |
| 提供方 401/403 | AUTH |
| 限流 | RATE_LIMIT |
| 上下文超限 | CONTEXT_WINDOW_EXCEEDED |
限制与参考实现
- 一次适配器调用是一次提供方尝试。服务本身不重跑请求;重试是
dsh-llm-retry在 agent 失败步边界上执行策略的职责。 - 模型目录是建议性的,路由仍以注册的 provider 路由为准;不要基于
listModels()的缺失做请求校验。 BlockAssembler只处理核心块类型:插件自加的块类型如果没有block-end关闭,blocks()会抛错。
仓库中有两个完整的参考实现,指南建议对比它们,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现:
- packages/llm/llm-deepseek — 直接
fetchDeepSeek chat-completions 的适配器,SSE 到StreamChunk的转换在 translate.ts; - packages/llm/llm-pi-ai — 基于库、走不同 API 格式的多提供方适配器。
如果适配器要作为新的工作区包进入本仓库,按 adding-a-package 手册补齐package.json、tsconfig.json、README 并注册到根配置,然后执行手册中的验证序列:
pnpm install # registers the workspace pnpm run doc-sync pnpm run constraints && pnpm run typecheck && pnpm run lint pnpm run build && pnpm run hygiene【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考