Appearance
第 14 章 LLM 适配器接缝
packages/llm/llm(约 950 行主文件 + 类型/消息/组装器)是 provider 无关的 LLM 词汇表与适配器接缝,注册为 ctx.llm。它是循环、会话日志、所有插件之间的共同语言。
14.1 消息词汇(message.ts)
ts
interface Message {
id: MessageId // crypto.randomUUID() 铸造
role: 'system' | 'user' | 'assistant'
content: ContentBlock[]
source: MessageSource // 谁生产的、什么形态
}三个角色特化:
UserMessage(role: 'user'):提示、注入上下文、目标续跑;AssistantMessage(role: 'assistant'):source 必带 provider + model;ToolResultMessage(role: 'user'):content 是单一tool-result块,source 携带callId——与调用耦合。
MessageSourceMap(可声明合并扩展):
ts
type MessageSource =
| { kind: 'user' }
| { kind: 'plugin'; plugin: string; form?: ContextForm }
| { kind: 'model'; provider: string; model: string; replayState?: unknown }
| { kind: 'tool'; callId: CallId }source.kind 回答"谁生产的",form 回答"是什么类型"——两轴独立。ContextForm(instructions/catalog/snapshot/notice/relay/recall)区分注入上下文的语义形态(非视觉)。所有消息构造(createMessage 等)都会 deep-freeze。
14.2 流式词汇(types.ts + assembler.ts)
StreamChunk 是流式协议,判别联合:
ts
type StreamChunk =
| { type: 'block-start'; index: number; blockType: string; ... }
| { type: 'text-delta' | 'reasoning-delta'; index; text }
| { type: 'tool-call-delta'; index; name?; arguments? } // 参数保持原始 JSON 字符串
| { type: 'block-end'; index; block: ContentBlock }
| { type: 'usage'; ... }
| { type: 'finish'; finish: FinishReason } // 终局,之后什么都不发FinishReasonMap(可扩展):stop | tool-calls | max-tokens | aborted{failure} | error{failure}。ContentBlockMap(可扩展):text | reasoning | image | tool-call | tool-result。
BlockAssembler(assembler.ts:36-164)把 chunk 流增量组装成消息:
- 容忍纯 delta 协议(无 block-start/end 也能组装);
- 对已关闭块再来的 delta 忽略——防恶意/损坏适配器膨胀流;
- max-tokens 截断时丢弃无法安全执行的 tool-call 块;
- 未闭合且未知的 blockType 抛错;
finish缺省{ kind: 'stop' }。
14.3 适配器接缝
LlmAdapter:唯一必需方法
ts
abstract class LlmAdapter {
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
// 可覆盖:providerInfo / providerRetryPolicy / listModels / async resolveModel
}一个适配器 = 一个 provider 的流式实现。GenerateOptions 是一次完整请求的参数:provider、model、reasoningEffort、messages、system、tools、temperature、maxTokens、stop、signal、sessionId、purpose('compaction' | 'session-title'——压缩与标题生成复用同一接缝)。
注册协议
ctx.llm.registerAdapter(providers, adapter):
- 原子全或无:多个 provider 一次性注册,重复注册抛
DUPLICATE_ADAPTER; - 返回
AdapterRegistrationHandle:既是 disposer 又带replace(providers)(换路由); commitRoutes以同步节切换路由,无观测空隙——HMR 重载适配器时请求不会"看到"半套路由。
prepareCall:冻结一次调用
prepareCall(index.ts:779-814)一次 exact-model 查询同时解析 config + context + retryPolicy + adapterDefaults,冻结后返回 one-shot stream:
- 复用或修改 config 抛
INVALID_PREPARED_CALL; - 把"header 日志"与"分发"绑定到同一 adapter 注册——防止 HMR 换适配器后日志 header 与分发器错配(README:37)。
llm/stream:waterfall
ts
// streamWithRegistration: ctx.waterfall(this, 'llm/stream', options, () => adapterStream(options, prepared))llm/stream 是 waterfall:监听者可以包裹(改写选项、包装流)、替换(自己出流)、或否决。最内层 next 是 adapterStream——终局边界:
- 适配器选择/迭代器构造/迭代失败 → 统一转成单个
finish{error|aborted, failure}; - middleware/嵌套调用/清理/下游消费者失败 → 保持抛异常(不吞掉调用方的错误)。
adapterFailureChunk 根据 signal.aborted 或错误 code 选择 aborted 还是 error 终局。
事件
| 事件 | 模式 | 语义 |
|---|---|---|
llm/stream | waterfall | 模型流式调用(循环与所有调用方必经) |
llm/adapters-updated | emit | 适配器拓扑变化通知 |
14.4 配置、错误与重试
LlmCallConfig:provider/model/reasoningEffort/temperature/maxTokens/stop——与日志request/header绑定;callConfigEquals做字段级实变化检测(决定是否写新 header);deepFreeze迭代防环、跳过 AbortSignal(防止冻结破坏取消机制);markAgentLoopRequest:区分循环构建的请求与独立辅助调用;LlmErrorextendsHarnessError:code为稳定字符串(NO_ADAPTER/DUPLICATE_ADAPTER/AUTH/RATE_LIMIT…),failure冻结序列化;ResolvedRetryPolicy(normal/always + backoff):服务只存储策略,不执行重试——重试由agent/request-error+dsh-llm-retry承担(第 11 章)。
14.5 具体适配器
packages/llm/ 下还有:
llm-deepseek:DeepSeek 官方适配器;llm-pi-ai:Pi AI 适配器;llm-retry:重试策略实现;token-meter:token 计量。
加一个 provider = 实现 LlmAdapter + registerAdapter(官方 cookbook:docs/cookbook/adding-an-llm-adapter.md)。
14.6 小结
- 消息词汇三件套:Message/ContentBlock/StreamChunk,全部可声明合并扩展、构造即冻结;
- 适配器唯一必需方法是
stream();注册原子全或无、路由可热换; prepareCall冻结调用并绑定 adapter 注册,防 HMR 错配;llm/stream是 waterfall:可包裹/替换/否决;失败在终局边界归一为 finish;- 重试不在接缝内,而在
agent/request-error。
下一章:能力接缝——执行与沙箱。