跳到主要内容

对接模型:懒加载适配器、OpenAI 兼容、推理round-trip、上下文压缩

30 秒导读: 这一层解决两件事。第一,把十几家模型抹成同一个接口——上层只认 chatstream 两个方法,底下是 Anthropic 还是 OpenAI 还是 Ollama 无所谓;而且每家的 SDK 都用到才加载,所以装一个包不必把所有 SDK 都拖进来。第二,管住上下文别撑爆——模型的"思考(reasoning)"块跨厂商怎么传、超长对话每一轮怎么瘦身。

这一层在 CLAUDE.md 的分层表里叫 LLM 层;它的上游是 03-agent-loop-and-tools.md 讲的 Agent 对话循环——循环每转一圈就调一次本层的 adapter.chat(...)。上下文压缩这半章其实属于 Agent 循环,但因为它和"模型能吃多少 token"强绑定,放这里一起讲。


1. 这是什么(零基础也能懂)

一句话定义

模型接入层 = 一个"翻译中间件":框架内部有一套自己的消息格式(LLMMessage / ContentBlock),每家模型厂商有各自的 wire format(线上传输格式);适配器负责把内部格式翻成厂商格式发出去、再把厂商的回复翻回内部格式。

解决什么问题 / 给谁用

假设你写了一个多 Agent 系统,今天用 Claude,明天老板说太贵换成开源模型跑在自建 GPU 上,后天又想接 Gemini 试试效果。

  • 没有这一层:每换一次模型,业务代码里所有"调模型"的地方都要改一遍——请求怎么拼、工具怎么声明、流式怎么解析,每家都不一样。
  • 有这一层:业务代码永远只写 adapter.chat(messages, options)。换模型 = 换一个 provider 字符串。

它能做什么

  • 统一 13 家内置 provider(Anthropic / OpenAI / Gemini / Bedrock / DeepSeek / Grok / 豆包 / 混元 / MiniMax / MiMo / Qiniu / Azure-OpenAI / Copilot),外加一座通往 Vercel AI SDK 的桥。
  • 通过 baseURL 直连任何OpenAI 兼容端点——Ollama、vLLM、LM Studio、llama-server 都算。
  • 把不同厂商的"思考/推理"内容在多轮对话里尽量原样传回去(需要签名的原样传,传不了的降级成文本或干脆丢掉)。
  • 在对话变长时,按策略压缩历史消息,避免超出模型的上下文窗口。

用起来什么样

一个最小示例——注意业务侧只碰 createAdapterchat:

// 示意,非源码
import { createAdapter } from '@open-multi-agent/core'

// 换一家模型只改这一行的字符串(和 model 名)
const adapter = await createAdapter('openai') // 用 OPENAI_API_KEY
// const adapter = await createAdapter('anthropic') // 用 ANTHROPIC_API_KEY

const res = await adapter.chat(
[{ role: 'user', content: [{ type: 'text', text: '你好' }] }],
{ model: 'gpt-5.4', maxTokens: 1024 },
)
console.log(res.content) // 归一化后的 ContentBlock[]

想连本地 Ollama?给 createAdapter 传第三个参数 baseURL 即可,provider 仍写 'openai':

// 示意,非源码
const local = await createAdapter('openai', 'ollama', 'http://localhost:11434/v1')

一句话直觉

把这一层想成电源适配器:插头(你的代码)只有一种,墙上插座(各家模型)千奇百怪,适配器负责中间那层转换——而且只有你真插上某个国家的插座时,才去拿对应那副转换头(懒加载)。


2. 顶层全景(它大概怎么转)

部件一句话职责

部件干什么在哪个文件
LLMAdapter 接口定义每家适配器都得有 chat + streamsrc/types.ts:1509
createAdapter 工厂按 provider 名 switch,动态 import 出对应适配器src/llm/adapter.ts:76
openai-common.tsOpenAI Chat Completions 格式的共享转换逻辑src/llm/openai-common.ts
各 provider 文件每家一个类;OpenAI 兼容的都继承 OpenAIAdaptersrc/llm/openai.tsanthropic.ts
reasoning-fallback.ts推理块跨厂商传不了时,降级成 <thinking> 文本src/llm/reasoning-fallback.ts
validate.ts每次 chat/stream 入口校验消息数组形状src/llm/validate.ts:34
ai-sdk.ts通往 Vercel AI SDK 的桥(可选 peer ai)src/llm/ai-sdk.ts
AgentRunner 上下文策略每轮调模型前压缩历史src/agent/runner.ts:547

一次请求的数据流

图从上到下就是一次 adapter.chat() 的旅程。怎么读: 左边是框架内部格式,过一道适配器翻成厂商格式,拿到回复再翻回来。

你的代码
│ adapter.chat(messages, options)

┌─────────────────────────────────────────────┐
│ 某个 LLMAdapter(如 OpenAIAdapter) │
│ │
│ ① validate.ts 校验 messages 形状 │
│ ② 内部 LLMMessage[] ──翻译──▶ 厂商 wire 格式 │
│ (openai-common / anthropic 各自的转换器) │
│ 顺带处理 reasoning 块:原样回传?还是 │
│ 降级成 <thinking> 文本?还是丢弃? │
│ ③ 发 HTTP 请求给厂商 SDK │
│ ④ 厂商回复 ──翻译──▶ 归一化 LLMResponse │
└─────────────────────────────────────────────┘


归一化的 LLMResponse { content, stop_reason, usage }

createAdapter 是这一切之前的一步——它按 provider 名懒加载出上图里的那个适配器实例(见 §3.1)。

三个运行时依赖 + 其余懒加载

这是本层最重要的设计约束,CLAUDE.md 反复强调:整个框架的运行时依赖只有三个——@anthropic-ai/sdkopenaizod(package.jsondependencies)。

其它 SDK 全是可选 peer,靠动态 import() 懒加载:

SDK服务于声明位置
@aws-sdk/client-bedrock-runtimeBedrock 适配器peerDependenciesMeta.optional: true
@google/genaiGemini 适配器同上
@modelcontextprotocol/sdkMCP 工具桥同上
aiAI SDK 桥(ai-sdk.ts)同上

为什么这么设计: 只用 OpenAI 的人,不该被迫装 AWS SDK 和 Google SDK。把它们做成"用到才 import"的可选 peer,node_modules 就不会因为一个没用到的 provider 而膨胀。"只有三个运行时依赖"这句承诺,只覆盖 dependencies——peer 不算。


3. 核心原理(逐个机制,由浅入深)

3.1 懒加载适配器工厂

它要解决的小问题: 想支持 13 家 provider,但不想让用户装 13 家的 SDK。

思路: 用一个 switch 按 provider 名分发,每个 case 里才 await import('./xxx.js')——那家的 SDK 只有在这条分支真正命中时才被加载。

真实实现: createAdapterasync 函数(因为要 await import),核心是一串 case:

// 引自 src/llm/adapter.ts:82-101(节选)
switch (provider) {
case 'anthropic': {
const { AnthropicAdapter } = await import('./anthropic.js')
return new AnthropicAdapter(apiKey, baseURL)
}
// ...
case 'openai': {
const { OpenAIAdapter } = await import('./openai.js')
return new OpenAIAdapter(apiKey, baseURL)
}

createAdapter(src/llm/adapter.ts:76)的签名是 (provider, apiKey?, baseURL?, region?),四个参数各有分工:

参数作用回退
provider选哪家必填;不认识就抛错
apiKeyAPI 密钥缺省回退到各家的 env 变量(见下表);bedrock 不用
baseURLOpenAI 兼容端点地址缺省用官方端点;bedrock/copilot 会忽略并告警
region仅 bedrock 用的 AWS regionAWS_REGION'us-east-1'

穷尽性检查(妙处): default 分支把 provider 赋给 const _exhaustive: never——如果将来往 SupportedProvider 联合类型里加了新 provider 却忘了写 case,TypeScript 会在编译期报错(src/llm/adapter.ts:141-145)。

SupportedProvider 联合类型(src/llm/adapter.ts:42)就是这 13 家的字符串枚举:

// 引自 src/llm/adapter.ts:42
export type SupportedProvider = 'anthropic' | 'azure-openai' | 'bedrock' | 'copilot' | 'deepseek' | 'doubao' | 'grok' | 'hunyuan' | 'minimax' | 'mimo' | 'openai' | 'gemini' | 'qiniu'

API key 回退到 env 变量——各家适配器构造器里做的,以 OpenAI 为例:

// 引自 src/llm/openai.ts:95-100
constructor(apiKey?: string, baseURL?: string) {
this.#client = new OpenAI({
apiKey: apiKey ?? process.env['OPENAI_API_KEY'],
baseURL,
})
}

各 provider 的 env 变量对照(工厂函数文档注释里列全了,src/llm/adapter.ts:49-65):

providerAPI key env备注
anthropicANTHROPIC_API_KEY
openaiOPENAI_API_KEY
geminiGEMINI_API_KEY / GOOGLE_API_KEY两个都试
grokXAI_API_KEY
deepseekDEEPSEEK_API_KEY
doubao(豆包)ARK_API_KEY
hunyuan(混元)HUNYUAN_API_KEY + 可选 HUNYUAN_BASE_URL
bedrock无 key,走 AWS 默认凭证链regionAWS_REGION
copilotGITHUB_COPILOT_TOKEN / GITHUB_TOKEN,否则 OAuth 设备流

想加一家新 provider?按 CLAUDE.md 的 "Adding an LLM Adapter":实现 LLMAdapter(chat+stream)→ 往 SupportedProvider 加名字 → 在 createAdapter 里补一个 case 用动态 import。OpenAI 兼容的直接继承 OpenAIAdapter、复用 openai-common.ts

3.2 LLMAdapter 接口:chat + stream

它要解决的小问题: 上层想"要么一次拿完整回复、要么边生成边收"这两种模式,且不管底下是谁。

接口本身极简(src/types.ts:1509),核心就两个方法加一个可选的能力声明:

// 引自 src/types.ts:1509-1524(节选)
export interface LLMAdapter {
readonly name: string
chat(messages: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse>
stream(messages: LLMMessage[], options: LLMStreamOptions): AsyncIterable<StreamEvent>
// readonly capabilities?: { echoesReasoning: ... } // 见 §3.4
}
  • chat 返回一个完整的 LLMResponse(src/types.ts:161):{ id, content, model, stop_reason, usage }
  • stream 返回一个 AsyncIterable<StreamEvent>,增量 yield;序列保证是:0 个或多个 text / reasoning / tool_use 事件,最后恰好一个 done(成功)或 error(失败)。这条约定 OpenAI、Anthropic、AI SDK 三个适配器完全一致(见各自 stream 文档注释,如 src/llm/openai.ts:183-192)。

LLMChatOptions(src/types.ts:1458)把所有采样参数收进一个对象:modelmaxTokenstemperaturetopP/topK/minPfrequencyPenaltythinkingsystemPromptabortSignal,以及一个逃生舱 extraBody——它被 spread 在采样参数和结构性字段之间,所以能覆盖采样默认值、但覆盖不了 model/messages/tools/stream 这些传输级字段(src/types.ts:1475-1482)。LLMStreamOptions 目前就是 extends LLMChatOptions,没加字段(src/types.ts:1498)。

3.3 OpenAI 兼容:一套转换逻辑喂饱大半个货架

它要解决的小问题: OpenAI、Grok、DeepSeek、豆包、混元、MiMo、Qiniu、Azure、Copilot——这些都说 OpenAI 的 Chat Completions 方言,转换逻辑不该抄十遍。

思路: 把"内部格式 ⇄ OpenAI 格式"的转换全放进 openai-common.ts,OpenAIAdapter 用它;其它兼容 provider 继承 OpenAIAdapter,只改三样东西:默认 baseURL、env 变量名、name

看 DeepSeek 就是个极薄的壳(src/llm/deepseek.ts:23-48):

// 引自 src/llm/deepseek.ts:23-47(节选)
export class DeepSeekAdapter extends OpenAIAdapter {
readonly name = 'deepseek'
override readonly capabilities = { echoesReasoning: 'tool-use-only' as const }
constructor(apiKey?: string, baseURL?: string) {
super(
apiKey ?? process.env['DEEPSEEK_API_KEY'],
baseURL ?? 'https://api.deepseek.com/v1', // 官方端点做默认,但允许覆盖
)
}
}

Grok、豆包、混元、MiMo 全是同一个模子——只差 baseURL 和 env 名(src/llm/grok.ts:19doubao.ts:19hunyuan.ts:35mimo.ts:19)。

baseURL 就是通往本地/自建模型的钥匙。 因为 baseURL 直接透传给 OpenAI SDK 客户端,任何说 OpenAI 协议的服务端都能接:把它指向 http://localhost:11434/v1 就是 Ollama,指向 vLLM / LM Studio / llama-server 同理。provider 字符串仍写 'openai'

openai-common.ts 里的关键转换函数:

函数干什么位置
toOpenAIMessages内部 LLMMessage[] → OpenAI messages[]openai-common.ts:102
buildOpenAIMessageList前置 system 消息,再拼上转换后的对话openai-common.ts:448
fromOpenAICompletionOpenAI 回复 → 内部 LLMResponseopenai-common.ts:334
normalizeFinishReasonstopend_turnlengthmax_tokensopenai-common.ts:434
repairToolArgs修补本地模型给出的畸形工具参数openai-common.ts:299

一个非平凡细节——工具消息必须排在最前: OpenAI 要求 assistant 的 tool_calls 后面紧跟 tool-role 消息回应,中间插一条 user 消息就 400。所以当一条 user 消息同时含 tool_result 和文本时,toOpenAIMessages 把 tool 消息发、文本再发(openai-common.ts:129-148)。这条路径正好被 Agent 循环的"循环检测警告注入"踩到(它会往 tool_result 里追加一段文本警告)。

另一个坑——本地模型的容错: 本地模型经常不用 tool_calls 字段、而是把工具调用写进纯文本;也常把单参数工具的值用没转义的引号或 Python 三引号包起来。fromOpenAICompletion 因此有两道兜底:

  1. tool_calls 为空但文本里疑似有工具调用 → 交给 extractToolCallsFromText 从文本里抽(openai-common.ts:388-401);
  2. 参数 JSON.parse 失败 → repairToolArgs 尝试修复单参数 {"name": value} 形状(openai-common.ts:360-370)。

抽到工具但 finish_reason 还是 'stop' 时,会把它纠正成 'tool_use',好让 Agent 循环继续转下去(openai-common.ts:404-409)。

3.4 推理(reasoning)round-trip:默认丢弃,可选转 <thinking>

这是本层最微妙的一块,也是 CLAUDE.md 列为"不可见不变量"的一条。

背景——什么是 reasoning 块。 现代模型会输出"思考过程"。框架把它建模成 ReasoningBlock(src/types.ts:22),带三个关键字段:

字段含义
text思考文本(redacted 时为空)
signature厂商签名,证明这段推理是模型真生成的
redactedDataAnthropic 安全系统加密后的不可读推理载荷
provenance哪个适配器产出的这个块(抽取时盖章,如 'anthropic')

核心难题: 多轮对话里,上一轮模型的思考块要不要回传给下一轮?各家规矩完全不同,而且跨厂商回传会被拒——Anthropic 严格校验签名,拿别家的推理块(没有它的签名)去回传会被拒绝。

框架的不变量(CLAUDE.md 原文):

Reasoning is dropped unless opted in —— 目标适配器无法原样回传(echo)的推理块,默认静默丢弃;除非打开 AgentConfig.preserveReasoningAsText,这时它被转成内联 <thinking> 文本。<thinking> 文本永远不会被反向解析回签名块。

这条不变量由 capabilities.echoesReasoning 三种取值驱动(src/types.ts:1554):

取值含义谁用
'never'wire 协议根本不收推理输入OpenAI Chat Completions、AI SDK 桥
'own-issued'只有 provenance === this.name 才尝试原样回传,且可能还有附加条件Anthropic(还需 signature)
'tool-use-only'工具对话里每条 assistant 消息都回传(gated by provenance)DeepSeek V4 思考模式

方向一:抽取时盖 provenance。 OpenAI 回复里的 reasoning_content 被抽成 ReasoningBlock 时,盖上本适配器的 name 当 provenance(src/llm/openai.ts:176this.name 传给 fromOpenAICompletion,后者在 openai-common.ts:347-353 盖章)。Anthropic 的 thinking 块则盖 provenance: 'anthropic' 并保留 signature(src/llm/anthropic.ts:210-221)。

方向二:回传时按能力分流。 以 Anthropic 的出站转换器为例,它对一个推理块的处理顺序清清楚楚(src/llm/anthropic.ts:82-113):

拿到一个 reasoning 块,要发给 Anthropic:
├─ 是自家产的(provenance==='anthropic')且有 redactedData?
│ → 原样发 redacted_thinking 块
├─ 是自家产的且有 signature?
│ → 原样发 thinking 块(带 text + signature)
├─ 否则(外来的 / 没签名):
│ ├─ 用户没开 preserveReasoningAsText? → 返回 null,静默丢弃
│ └─ 开了? → 降级成 <thinking>…</thinking> 文本块

降级的实现在 reasoning-fallback.ts 核心函数 reasoningBlockToInlineText(src/llm/reasoning-fallback.ts:176):

  • redactedData 非空 → 返回固定占位符 <thinking>[redacted]</thinking>,绝不泄露加密载荷;
  • 空文本 → 返回空串(调用方应跳过、别塞空块);
  • 否则 → <thinking>${truncate(text)}</thinking>,默认截断到 DEFAULT_REASONING_FALLBACK_MAX_CHARS = 1200 字符(reasoning-fallback.ts:38),头部拿约 70% 预算好让模型多看到前面的推理步骤(truncate,reasoning-fallback.ts:151-158)。

截断上限由 resolveReasoningOutboundMaxChars(reasoning-fallback.ts:124)从用户的 preserveReasoningAsText + compressReasoningText 解析:关掉压缩就返回哨兵 NO_TRUNCATION(= +Infinity,reasoning-fallback.ts:49),映射成 MAX_SAFE_INTEGER 即不截断。

安全契约(单向不变量),写在文件头注释里(reasoning-fallback.ts:14-20): 反方向——把 <thinking> 文本解析回 ReasoningBlock——绝不允许发生。重建出来的块没有可验证签名,再发给 Anthropic/Bedrock/Gemini 会被拒。ReasoningBlock 只从原生 API 抽取产生(且总盖 provenance),从不从文本解析产生。

OpenAI 家的原生回传(DeepSeek 特例): DeepSeek V4 思考模式要求工具对话里每条中间 assistant 消息都回传 reasoning_content,连最后那条没有 tool_calls 的合成消息也不能漏,否则下一轮 400。toOpenAIMessages 因此先扫全对话是否含 tool_use,再决定要不要把 provenance 匹配的推理拼成 reasoning_content 字段挂上去(openai-common.ts:117-119280-284)。这条路由由 nativeReasoningEchoProvider 选项开关,只有 'tool-use-only' 能力的适配器会设它(src/llm/openai.ts:121-130)。

3.5 AI SDK 桥与入口校验

ai-sdk.ts 桥(src/llm/ai-sdk.ts)。 AISdkAdapter 把框架接到 Vercel AI SDKgenerateText / streamText(可选 peer ai)。当 AgentConfig.adapter 被显式设置时,Agent 跳过 createAdapter、直接用它——于是任何 @ai-sdk/* provider 都能接进来。它的能力也是 'never'(ai-sdk.ts:239),但有个刻意的例外:opt-in 关着时,它保留 Phase-2 之前的向后兼容行为,把推理块当成 AI SDK 的结构化 { type: 'reasoning' } 透传,而不是丢弃(ai-sdk.ts:75-93)——因为不想悄悄改变现有 AI SDK 用户看到的东西。

validate.ts 入口校验(src/llm/validate.ts:34)。 LLMMessage.content 类型上是 ContentBlock[],但 JS 调用方、反序列化的历史、自定义集成都可能在运行时破坏这个契约。没有守卫的话,一个非数组 content 会在 provider 转换深处崩出一句看不懂的 TypeError: content.some is not a functionassertValidMessages每个适配器的 chat/stream 入口被调用(如 src/llm/openai.ts:144anthropic.ts:348),把坏契约在边界处变成清晰的 InvalidMessageError,并指出出错的下标。它故意很窄:只校验数组/形状,不校验 role 或块内字段;拒绝坏输入而非强行纠正,好让调用方的 bug 保持可见。


4. 上下文治理:每轮调模型前给历史瘦身

对话越长,喂给模型的 token 越多,直到撑爆上下文窗口。AgentRunner每一次 LLM 调用之前都会跑一遍上下文策略(src/agent/runner.ts:798-809):

// 引自 src/agent/runner.ts:799-808(节选)
if (this.options.contextStrategy) {
const compacted = await this.applyContextStrategy(
conversationMessages, this.options.contextStrategy, baseChatOptions, turns, options,
)
conversationMessages = compacted.messages
totalUsage = addTokenUsage(totalUsage, compacted.usage) // summarize 会消耗 token
}

ContextStrategy 是个联合类型,四选一(src/types.ts:125-152):

策略一句话要不要额外调模型
sliding-window只保留最近 N 轮
summarize把老对话喂给模型让它总结(多花 token)
compact规则化裁剪(截断长文本、压缩工具结果)
custom你自己给一个 compress 回调看你实现

分发在 applyContextStrategy(src/agent/runner.ts:547),就是按 strategy.type 转到对应私有方法。

4.1 sliding-window:留住开头和结尾

思路: 系统级的第一条 user 消息(原始目标)永远留;从尾巴往回数,凑够 maxTurns * 2 条消息为止;中间的丢掉,并绝不切开 tool_use/tool_result 这种原子对。

实现是 truncateToSlidingWindow(src/agent/runner.ts:390)。几个巧处:

  • 第一条 user 消息单独留出来再拼回结果头部(runner.ts:395-429)——它是原始目标,不能被窗口滑掉。
  • 按"turn"而非按消息切:先 groupIntoTurns 把消息分组,从尾部累加 turn 直到够 target = maxTurns * 2(runner.ts:407-419)。切点若落在一个原子工具 turn 中间,宁可多留一条也不切开。
  • 丢掉了内容就插一条通知 [Earlier conversation history truncated — N turn(s) removed](runner.ts:431-436)。

4.2 summarize:让模型自己总结老对话,并带缓存

思路: 估算 token 超过阈值时,把"较老的一半"喂给模型总结成几句要点,用要点替换掉那段原文;"较近的一半"原样保留。

实现是 summarizeMessages(src/agent/runner.ts:442)。要点:

  • 门槛守卫:估算 token(estimateTokens,一个纯字符启发式,src/utils/tokens.ts:7)没超阈值、或消息太少(< 4),直接原样返回(runner.ts:450-453)。
  • 偶数边界切分:splitAt = max(2, floor(rest.length/4)*2),保证不把 tool_use assistant turn 和它的 tool_result user 消息切开(runner.ts:466-470)。
  • 图片先剥离:总结时会 JSON.stringify 老消息,若含 ImageBlock 会把整段 base64 塞进总结 prompt——一张 1MB 图能让"压缩"调用暴涨约 25 万 token,适得其反。所以先用 stripImageBlocksForSummary 把图换成占位文本 [image: <mime>](runner.ts:292-303478)。

summarizeCache 是关键优化(src/agent/runner.ts:372)。 对话每转一圈都会调 summarize,但"较老的那一半"往往一模一样。缓存以老片段的序列化字符串当 oldSignature:命中就直接复用上次算好的 summaryPrefix,跳过一次真实的总结 LLM 调用(runner.ts:480-486),省钱省延迟。未命中才真去调模型总结、并写回缓存(runner.ts:513-536)。summarize 是唯一会返回非零 usage 的策略——因为只有它真调了模型。

4.3 compact:纯规则裁剪,不调模型

思路: 不花钱(不调模型),靠规则压:保住对话骨架(所有决策类的 tool_use 块),但把冗长的工具结果和长文本替换成短标记。

实现是 compactMessages(src/agent/runner.ts:1158)。规则(文档注释在 runner.ts:1149-1157):

  • tool_use 块永远保留——那是"模型做过什么决策"的骨架。
  • 长 assistant 文本(≥ minTextBlockChars,默认 2000)→ 截成头部摘录 + [truncated — N chars total](runner.ts:1224-1230)。
  • 长 tool_result(≥ minToolResultChars,默认 200)→ 换成 [Tool result: <name> — N chars, compacted](runner.ts:1256-1261)。
  • 错误结果永不压(is_error 的 tool_result 原样保留,runner.ts:1242)。
  • 委派结果永不压——delegate_to_agent 的结果保留,因为父 Agent 可能还要基于它推理(runner.ts:1253-1254)。
  • 最近 preserveRecentTurns(默认 4)轮原样保留——模型马上要看的东西不动(runner.ts:11671182-1187)。
  • 全程若没有任何块被改动,返回原数组(runner.ts:1274),避免无谓地重建对象。

关于工具结果压缩,还有一个更轻量的、与 compact 正交的 compressConsumedToolResults(runner.ts:1287):它在上下文策略之前先跑(runner.ts:794-796),只压"已被消费"(assistant 已在其后产出过回复)的工具结果,永远保留最后一条 user 的工具结果。


5. 巧妙之处(可借鉴的技术)

  • 穷尽性 never 守卫:createAdapterdefault 分支把 provider 赋给 const _exhaustive: never——加了新枚举值忘写 case,编译期就报错。同样手法也用在 Anthropic 内容块转换的 default(src/llm/anthropic.ts:154-158)。src/llm/adapter.ts:141-145
  • 继承抹平 OpenAI 方言:9 家 OpenAI 兼容 provider 靠继承 OpenAIAdapter 复用全部转换逻辑,每家的差异被压缩到"三行构造器 + 一个 name"(如 src/llm/grok.ts:19-27)。
  • provenance 盖章 + 单向 <thinking>:推理块的"来源"在抽取时就盖死,回传时凭它决定原样传/降级/丢弃;且降级是不可逆的——重建签名块会被厂商拒,所以框架从不反向解析。src/llm/reasoning-fallback.ts:14-20
  • redacted 推理绝不泄露:遇到加密推理载荷,降级路径只吐固定占位符 <thinking>[redacted]</thinking>,AI SDK 桥同样只吐 [redacted_thinking] 标记,从不把 redactedData 写进请求体(reasoning-fallback.ts:180-182ai-sdk.ts:88-92)。
  • summarize 缓存:老对话片段的签名一致就跳过整次总结 LLM 调用——对话循环里这段几乎每轮都相同,收益很大。src/agent/runner.ts:372480-486
  • 总结前剥图:防止 base64 图片把"压缩"调用反向撑爆。src/agent/runner.ts:292-303
  • 入口形状校验:把"content 不是数组"这类破契约,从 provider 深处的 TypeError 提前到边界处的清晰 InvalidMessageErrorsrc/llm/validate.ts:34

6. 边界与局限

  • estimateTokens 是字符启发式,不是真 tokenizer(src/utils/tokens.ts:7)。它刻意不依赖任何 model-specific 分词器,所以 summarize/compact 的触发阈值是近似的,可能偏保守或偏激进。
  • summarize 会额外花钱花时间——它真的调了一次模型。缓存能省重复调用,但首次和"老片段变化后"仍要付费。
  • <thinking> 文本降级会丢结构:一旦降级,推理就是普通文本,无法再被当作签名块回传;跨厂商的多轮 extended-thinking 严格来说是不连续的。
  • baseURL 不是万能:copilot 和 bedrock 会忽略 baseURL 并告警(src/llm/adapter.ts:88-89137);OpenAI 兼容端点的容错(text tool-call 抽取、参数修补)只在 tool_calls 为空时兜底,原生 tool_calls 永远优先。
  • Anthropic thinking 有硬约束:budgetTokens 必须 ≥ 1024 且 < maxTokens,否则适配器提前抛错而非等 API 返 400(src/llm/anthropic.ts:285-308)。

7. 横向对比

  • 同库其它章:调度看 01-coordinator-and-scheduling.md,任务图执行看 02-task-queue-and-execution.md,Agent 循环(本层的直接上游)看 03-agent-loop-and-tools.md,共享内存/共识/观测看 05-memory-consensus-observability.md。全景与阅读地图见 index.md
  • 设计取舍:本项目坚持"运行时依赖只有三个 + 其余懒加载可选 peer",把多 provider 支持的成本从"用户被迫装一堆 SDK"转移到"工厂里动态 import"。这与那些把所有 provider SDK 都列进 dependencies 的框架是相反的取舍——代价是 createAdapter 必须是 async,收益是干净的依赖树。

8. 代码地图(导航索引)

主题文件路径符号名
适配器接口(chat+stream+capabilities)src/types.tsLLMAdapter
chat 选项 / 流式选项src/types.tsLLMChatOptions / LLMStreamOptions
归一化响应src/types.tsLLMResponse
推理块 IRsrc/types.tsReasoningBlock
上下文策略联合类型src/types.tsContextStrategy
懒加载工厂src/llm/adapter.tscreateAdapter
支持的 provider 枚举src/llm/adapter.tsSupportedProvider
OpenAI 消息转换(出站)src/llm/openai-common.tstoOpenAIMessages / toOpenAIAssistantMessage
OpenAI 响应转换(入站)src/llm/openai-common.tsfromOpenAICompletion
finish_reason 归一化src/llm/openai-common.tsnormalizeFinishReason
畸形工具参数修补src/llm/openai-common.tsrepairToolArgs
OpenAI 适配器(基类)src/llm/openai.tsOpenAIAdapter
Anthropic 适配器src/llm/anthropic.tsAnthropicAdapter
Gemini 适配器src/llm/gemini.tsGeminiAdapter
Bedrock 适配器src/llm/bedrock.tsBedrockAdapter
DeepSeek(继承示例 + 能力覆盖)src/llm/deepseek.tsDeepSeekAdapter
推理降级成 <thinking> 文本src/llm/reasoning-fallback.tsreasoningBlockToInlineText
截断上限解析src/llm/reasoning-fallback.tsresolveReasoningOutboundMaxChars
出站选项类型src/llm/reasoning-fallback.tsReasoningOutboundOptions
AI SDK 桥src/llm/ai-sdk.tsAISdkAdapter / llmMessagesToAiSdkModelMessages
消息形状校验src/llm/validate.tsassertValidMessages
上下文策略分发src/agent/runner.tsapplyContextStrategy
sliding-window 裁剪src/agent/runner.tstruncateToSlidingWindow
summarize + 缓存src/agent/runner.tssummarizeMessages / summarizeCache
compact 规则裁剪src/agent/runner.tscompactMessages
token 估算src/utils/tokens.tsestimateTokens