跳到主要内容

适配器:把 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:7)的骨架:

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_chunkadapters.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:164)有 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:192)按这个路径把设置值写到请求体的正确嵌套位置——支持 reasoning.effort 这种点号嵌套 key。这让「加一个可调参数」只需在 schema 加一行,不用动请求拼装逻辑。

5. HTTP 客户端:适配器之下的传输层

适配器只管「格式」,真正发请求的是 http.luaClientencode_body(http.lua:95)把多个 handler 的输出 tbl_extend 合并成请求体;Client:send(http.lua:204)用 plenary.curl 发流式请求,按 adapter.opts.stream 决定走流式回调还是一次性回调。它还把 curl 的静态方法做成可注入的 static.methods(http.lua:20),方便测试时 mock。

6. 边界与局限

  • 适配器表是模块级共享的;请求前 vim.deepcopy(http.lua:80prepare_adapter)避免并发请求互相污染。
  • 不是每家都支持所有能力(vision、tools、compaction),靠 opts/features 标志位声明,核心据此降级(如不支持 vision 就丢掉图片消息,anthropic.lua:210)。
  • send_sync 注释里留着一段「同步不支持 stream」的死代码(http.lua:298),实际放开了——是演进中的痕迹。

7. 横向对比

「适配器/handler 表」是几乎所有多模型 agent 的通用解法。CodeCompanion 的特点是把它做得极声明式:一家模型 = 一个返回大表的文件,连 header、env 变量名、能力标志都在表里。社区因此能很容易贡献新适配器(README 列了一长串社区适配器)。下一章的 ACP 是另一条完全不同的路——不发 HTTP,而是把 agent 当子进程。

8. 代码地图

主题文件符号
适配器工厂lua/codecompanion/adapters/init.luaM.resolveM.call_handleradapter_type
HTTP 适配器基类lua/codecompanion/adapters/http/init.luaAdapter.newAdapter:map_schema_to_paramsAdapter:extend
共享 handlerlua/codecompanion/adapters/http/openai.luaform_messageschat_outputinline_output
范例适配器lua/codecompanion/adapters/http/anthropic.luahandlers.form_messageshandlers.setup
传输层lua/codecompanion/http.luaClient:sendencode_bodyprepare_adapter