给 dsh 写一个 LLM 适配器:接 OpenAI 兼容端点
在 dsh 里接一个新模型 provider 有两条路:端点方言不重,走配置,在 llm-pi-ai 插件里声明一条路由就够;方言重到配置开关表达不了,才写适配器插件。写适配器本身不难,难在守住 chunk 协议的三组承诺:流的形状、token 口径、错误语义。每一组承诺都对应 harness 里一个具名的下游消费者,漏守一条,bug 会藏进 token 计费、上下文压缩、错误恢复这些漂移型故障里。
先分两条路:配置,还是代码
拿到一个 OpenAI 兼容端点(自建 vLLM、第三方网关、任何实现了 chat completions 的服务),第一反应不该是写代码。dsh 的 llm-pi-ai 插件本身就是"接任意 OpenAI 兼容端点"的配置通道:pi-ai 目录里没有的路由可以直接声明,给出路由名、baseURL、协议名,再配一份模型清单。
providers:
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
compat:
maxTokensField: max_tokens
models:
- id: acme-large
contextWindow: 65536
maxTokens: 4096
端点和标准 OpenAI 协议有出入时,compat 开关吸收差异:maxTokens 字段叫什么、思考内容用什么格式回传、developer 角色能不能用。模型清单可以现场探测,探测走 GET /models,返回超过 4 MiB 直接拒收,结果只是候选,不落盘。
判断条件就一条:方言能不能被 compat 开关和现有协议表表达。能,走配置,一天能接十个网关;不能,或者你要直接掌控 HTTP 层,写适配器。仓库里两个官方适配器正好是两条路的标本:llm-deepseek 自己发 fetch、自己解析 SSE,llm-pi-ai 包装 pi-ai 库。
写适配器的注册动作很小。一个 Cordis 插件文件,适配器类继承 LlmAdapter、实现一个流式方法,插件入口把实例注册到你声明的 provider 路由数组上,文件以路径挂进 cordis.yml 就能加载。注册是可逆副作用,插件卸载自动撤销,热重载不留垃圾;一条路由只允许一个适配器,重复注册整批失败,杜绝"同一个 provider 有两个适配器、运行期不知道走哪个"的歧义。路由选适配器,模型 id 不在生命周期里登记,所以一个适配器能服务目录里还没有的模型。接不接受没登记的模型由适配器自己决定:llm-deepseek 接受任意模型 id,llm-pi-ai 的手写路由没列就拒,两种都合法。
凭证只放引用。配置里写的是环境变量名,每个请求经凭证服务解析一次,字面密钥不是配置值。能力问题也归适配器答:上下文窗口、默认输出上限、推理档位,一次查询拿全。推理档位是适配器拥有的不透明 id 列表,id 不必等于它在 wire 上的拼写,比如"关思考"这个档位根本不上 wire,直接翻成关闭思考的开关;调用方选了不支持的档位,拒绝,不做夹紧。
适配器只做翻译,chunk 之后的事不归它管
适配器收到的请求是 provider 中立的:系统提示、消息历史、工具 schema、采样参数、一个取消信号。它的工作是把这份请求翻译成端点的 HTTP 调用,再把端点的流式响应翻译成一种统一的块事件流吐出去:块开始、各类增量、块结束、用量、终态。这个词表是闭合的,消费者用穷举分支处理,新增一种事件会在每个消费者处编译失败,这是刻意的。
分工的另一半更关键:适配器只要吐出合法的事件,块重组不是它的事。harness 里有唯一的组装器把事件流折回完整消息,agent 循环一边把原始事件落会话日志(重放保真),一边喂同一个组装器。适配器中途抛出的异常也逃不出去,运行时把它归一成一条终态的失败事件。边界干净:适配器管方言翻译,harness 管拼装、归一化、日志、重放。
边界干净不等于责任轻。下游的一切都建立在"你吐的事件说话算数"上。
流的形状:三条硬规矩
第一条:用量必须在终态事件之前,终态之后什么都不许再发。现实里的 provider 什么形态都有,有的把用量挂在最后一个带内容的块上,有的在流末尾单独补一个只有用量的尾巴块。稳妥做法是把终态和用量都缓冲到端点的流结束标记(OpenAI 系是 [DONE]),再一起发。llm-deepseek 走得更远:所有块的结束事件也压到 [DONE] 才发,增量照常实时流,整块和终态最后一次性交清,对"尾巴只有用量"的形态免疫。
第二条:工具调用的参数全程是原始 JSON 字符串。provider 分片给的就按增量透传;像 pi-ai 这种库直接给解析好的对象,适配器要在块结束时重新序列化。这条规矩防的是账目分裂:工具执行管线拿到的就是这串字符,任何一处偷偷解析再重新拼,空格、键序、转义全变,和日志里、用户看到的版本对不上。
第三条:块的 index 按首次出现顺序分配,同一个块的每个增量复用同一个 index。流式响应里文本、推理、多个工具调用是交错的,index 是把它们重新编队的唯一线索。
漏掉任何一条,先砸组装器,再砸所有把"消息"当事实的子系统。还有一个容易被当成无害的情况:模型什么都没生成就正常结束。这不能算成功。空补全映射成带专用 code 的失败,默认重试策略认它,重复一次是安全的。
终态事件还有一个可选项:如果 provider 要求后续请求带回响应 id 或签名之类的原生状态,把它的最小投影挂在终态一起发出。运行时只在历史路由和目标路由同属一个适配器实例时才把状态传回,换 provider 就降级为中立内容加诊断。OpenAI 系的 chat completions 通常无状态,用不上;用不上就别加。
token 口径:三个不相交的桶
接完端点最常见的症状:能对话,但计费和上下文压力全对不上。原因多半在口径。OpenAI 系的用量把缓存命中折进一个 prompt 总数;dsh 的口径是三个不相交的桶:未缓存输入、缓存读、缓存写,计费输入是三项之和。适配器有责任把缓存部分从总数里减出来。推理 token 是信息性字段,已经含在输出里,不许再加一遍。
这个口径不是洁癖。会话日志上有一个用量折叠服务,逐条累计每个成功调用的用量;压缩插件靠它的压力读数决定什么时候收窄历史。计数重叠或漏减,压力曲线就是斜的,压缩要么提前触发,要么永远不触发。两个官方适配器都为这个减法专门写了映射和单测。
错误语义:两条出口,一套稳定 code
适配器的失败只有两条合法出口:从流式方法里抛出,用于传输和协议层失败;或者用带失败载荷的终态事件收流,用于流已经开始后 provider 在带内报错。两条出口最终归一成同一种结构化失败,但哪类失败走哪条,要定死并写进文档。
比出口更重要的是 code。每个失败带一个稳定的机器路由 code,消费者只按 code 行事,从不读 provider 的报错原文。这套 code 真的有人在路由:agent 级重试插件默认认五个 code(空响应、限流、服务端错误、超时、传输),默认重试五次,指数退避 500 毫秒到 10 秒;上下文超长归一成专用 code,压缩子系统靠它知道该收窄历史了;认证、配额、无效请求各有 code,决定重试还是立刻放弃。你的适配器把限流报成通用错误,恢复就少一层;把上下文超长报成普通 400,压缩永远不会被触发,会话卡死在同一个报错上。
配套的还有三条边界义务。一次适配器调用等于一次 provider 尝试:自己包的 HTTP 库要显式关掉它的重试,否则和 agent 级恢复叠加,行为不可控。请求里 provider 兑现不了的字段要抛"不支持",不许静默丢弃,静默丢弃会让调用方以为生效了。每个 HTTP 请求带上应用归因头,并且用 wire 级测试证明它真的发出去了。取消信号要透传;流式读挂一个空闲看门狗,两个官方适配器默认五分钟没有新事件判超时,SSE 的心跳注释算活跃证据。
OpenAI 兼容是光谱,历史是持久的
没有哪个端点是完整兼容 OpenAI 的,各自在协议上长方言。llm-deepseek 的序列化层记满了这类现实:
- 无文本的 assistant 回合发空字符串,绝不发 null。有的网关直接拒收 null;更糟的是官方端点对纯推理回合的 null 内容回 400,而这条消息已经持久落在会话日志里,一次坏序列化让这条会话的每一轮后续请求都被同一个 400 拒掉。
- 可选字段省略不发,不发 null,让 provider 的默认值生效。
- 空的工具输出也要给占位文本。
- 词表外的结束原因(内容审查、资源不足、未来新增)映射成带 code 的失败,不冒充正常停止。
- 思考型模型的第一个增量常是空字符串,不能据此开块。
这里有个放大器:适配器翻译的不只是当前请求,是整条持久历史,每一轮都把全部历史重新序列化一次。所以序列化里每个方言处理都是永久投资,而序列化 bug 的代价是整条会话,不是一次请求。
两个官方适配器吸收方言的方式值得对比。direct HTTP 路线把方言全握在自己手里:HTTP 状态码、retry-after 头、响应体细节都能读,错误分类精确;代价是协议细节全要自己写。包装库路线协议白拿,但库会把错误压扁成一个 message 字符串,原始的 cause 链在路上丢了,错误分类退化为正则匹配文本,还受制于上游修不修。llm-pi-ai 的 compat 开关是把方言再往配置层推一步:让部署声明端点的怪癖,而不是给每种网关发一个适配器。
权衡
契约的厚度是一笔双向账。适配器要守的规矩多:三条流形状、一套 token 减法、一套 code 纪律、一次一次尝试。换来的是消费端变薄:组装器不用防备胡乱的事件流,重试插件只认 code,压缩只看压力读数,谁都不需要理解任何一个 provider。反过来,规矩漏守的 bug 都长在最难复现的地方,计费漂移、压缩时机漂移、恢复失效,每个都要跨子系统追。
整块缓冲到流结束标记的取舍:增量实时流,整块和终态最后交清,换来对尾巴块形态的免疫;代价是消费者拿到完整块的时间推迟到流尾,对渲染无感,对想在块完成后立刻动手的消费者有延迟。
两条路本身的取舍:配置路快,但方言超出 compat 开关就没有余地;代码路什么都能做,但每个方言处理都是你要维护的代码。先配置后代码,方言顶破开关的那天再写适配器,这时 llm-deepseek 的目录布局就是模板:wire 类型、请求序列化、传输解析、事件翻译、适配器类五块分开,序列化和传输可以脱离真实端点单测。
结论
接一个 OpenAI 兼容端点,先走配置:llm-pi-ai 的手写路由加 compat 开关,能覆盖大多数网关,方言顶破开关再写适配器。适配器是翻译层,请求翻译过去,块事件流翻译回来,拼装、归一化、日志、重放全部交还 harness。它的难度集中在三组承诺上:流的形状(用量先于终态、参数全程原始字符串、index 首见分配、空补全算失败)、token 的不相交口径(缓存从总数里减出来,否则压力曲线是斜的)、错误语义(两条出口、稳定 code、一次调用一次尝试)。每条承诺都有具名消费者,code 是重试和压缩的路由键,漏守就砸一个子系统。方言处理里最贵的教训是持久历史:序列化 bug 不是一次失败,是把这条会话写坏。
延伸阅读
- Cookbook: adding an LLM adapter:适配器契约的官方浓缩版
- LLM Streaming 子系统文档:块事件协议与适配器契约的完整定义
- 用户指南:Providers:配置路接 provider 的完整参数
- 开发实践:LLM adapters:最小实现与注册示例
- dsh-llm-deepseek README:direct HTTP 参考实现
- dsh-llm-pi-ai README:多 provider 配置与 compat 开关
上一篇:多模态与 Attachment:dsh 怎么让 agent"看图" 下一篇:沙箱、审批与权限:dsh 怎么安全地放 agent 上机
GitHub 原文:18-write-an-llm-adapter.md
评论
EMPTY