数据截至 (上游 commit 6fb43a175495)
适配器:把 20+ 家 LLM 抹成一个接口
这一章讲:为什么核心代码从不关心「现在连的是 Anthropic 还是 OpenAI」,以及一家新模型怎么靠一张表接进来。
1. 要解决的小问题:每家 API 都不一样
Anthropic 的请求体把 system 单独拎出来、用 content 数组;OpenAI 把 system 当成一条普通 message;Gemini 又是另一套。流式回话的 chunk 格式更是各不相同。如果核心循环里到处 if adapter == "anthropic",代码会烂掉。
适配器模式把这些差异收口到一张表里:每家 LLM 写一个 Lua 表,实现一组约定好的 handler。核心只对这组 handler 编程。
2. 一个适配器长什么样
适配器是一张声明式表。看 Anthropic(adapters/http/anthropic.lua:20)的骨架:
return {
name = "anthropic",
url = "https://api.anthropic.com/v1/messages",
env = { api_key = "ANTHROPIC_API_KEY" }, -- 从环境变量取密钥
headers = { ["x-api-key"] = "${api_key}", ["anthropic-version"] = "2023-06-01" },
roles = { llm = "assistant", user = "user" }, -- 角色名映射
opts = { stream = true, tools = true, vision = true },
handlers = {
setup = function(self) ... end,
form_parameters = function(self, params, messages) ... end,
form_messages = function(self, messages) ... end, -- 把统一消息 → Anthropic 格式
chat_output = function(self, data, tools) ... end, -- 把 chunk → 统一结构
-- …
},
schema = { model = { ... }, temperature = { ... } }, -- 可调参数 + 到 API 字段的映射
}
关键 handler(各家都要实现):
| handler | 干什么 |
|---|---|
setup | 请求前准备(开 stream、按模型设能力等) |
form_parameters | 拼请求参数(温度、思考预算…) |
form_messages | 把统一消息表翻成这家 API 的请求体格式 |
chat_output / parse_chat | 把这家的流式 chunk 解析成统一 { status, output } |
form_tools | 把工具 schema 翻成这家的工具格式 |
format_response | 把工具执行结果翻成这家的 tool_result 格式 |
3. 运行时怎么分发:工厂 + call_handler
核心代码从不直接调 anthropic.handlers.form_messages。它走两层间接:
工厂解析(adapters/init.lua:37):M.resolve(adapter) 先看是 http 还是 acp(adapter_type,按名字在 config 里查,init.lua:8),分发到对应子工厂实例化。
统一调用(adapters/init.lua:102):M.call_handler(adapter, name, ...) 取出该适配器的 handler 调用,没有就返回 nil。01 章的 process_chunk 里 adapters.call_handler(adapter, "parse_chat", data, tools) 就是这么分发的——核心永远不知道自己在跟谁说话。
这段演示「对接口编程」的本质(# 示意,非源码):
-- 核心代码:完全不关心是哪家
local payload = adapter.form_messages(adapter, my_messages)
local result = adapter.chat_output(adapter, raw_chunk)
-- 换一家 LLM = 换一张 handler 表,核心一行不改
4. 两个值得看的细节
form_messages 是脏活的归宿。 Anthropic 的 form_messages(anthropic.lua:178)有 11 个编号步骤:抽离 system 消息、把字符串内容包成 {type="text"} 块、把 tool 角色转成 user、把 LLM 的 tool_calls 转成 tool_use 内容块、塞 reasoning 的 thinking 块、合并连续同角色消息……所有 Anthropic 特有的怪癖都关在这一个函数里,核心一概看不见。
schema → params 的映射。 用户能调的设置(model、temperature)定义在 schema 里,每项带一个 mapping 路径(如 "parameters.temperature")。map_schema_to_params(adapters/http/init.lua:195)按这个路径把设置值写到请求体 的正确嵌套位置——支持 reasoning.effort 这种点号嵌套 key。这让「加一个可调参数」只需在 schema 加一行,不用动请求拼装逻辑。
5. HTTP 客户端:适配器之下的传输层
适配器只管「格式」,真正发请求的是 http.lua 的 Client。encode_body(http.lua:121)把多个 handler 的输出 tbl_extend 合并成请求体;Client:send(http.lua:216)用 plenary.curl 发流式请求,按 adapter.opts.stream 决定走流式回调还是一次性回调。它还把 curl 的静态方法做成可注入的 static.methods(http.lua:25),方便测试时 mock。
6. 边界与局限
- 适配器表是模块级共享的;请求前
vim.deepcopy(http.lua:85的prepare_adapter)避免并发请求互相污染。 - 不是每家都支持所有能力(vision、tools、compaction),靠
opts/features标志位声明,核心据此降级(如不支持 vision 就丢掉图片消息,anthropic.lua:224)。 send_sync现在显式关掉 streaming:http.lua:313-316把adapter.opts.stream = false,注释明说「免得处理畸形 JSON chunk」——旧版「注释说同步不支持 stream、实际放开」的演进痕 迹已被这段真逻辑清理。
7. 横向对比
「适配器/handler 表」是几乎所有多模型 agent 的通用解法。CodeCompanion 的特点是把它做得极声明式:一家模型 = 一个返回大表的文件,连 header、env 变量名、能力标志都在表里。社区因此能很容易贡献新适配器(README 列了一长串社区适配器)。下一章的 ACP 是另一条完全不同的路——不发 HTTP,而是把 agent 当子进程。
8. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 适配器工厂 | lua/codecompanion/adapters/init.lua | M.resolve、M.call_handler、adapter_type |
| HTTP 适配器基类 | lua/codecompanion/adapters/http/init.lua | Adapter.new、Adapter:map_schema_to_params、Adapter:extend |
| 共享 handler | lua/codecompanion/adapters/http/openai.lua | form_messages、chat_output、inline_output |
| 范例适配器 | lua/codecompanion/adapters/http/anthropic.lua | handlers.form_messages、handlers.setup |
| 传输层 | lua/codecompanion/http.lua | Client:send、encode_body、prepare_adapter |