LLM Adapter 与会话协议转换
内部消息怎样投影到 DeepSeek、Pi AI 与重放适配器
结论:Adapter 翻译一次调用,而 Session Log 拥有会话
DeepSeek Harness 没有让任何 Provider SDK 成为对话状态的 owner。内部先形成不可变、带来源的 Message 和封闭的 StreamChunk 协议;Adapter 只负责把一次完整请求投影到某条 wire protocol,再把响应翻译回来。持久化、重试、重放和工具续跑都发生在 Adapter 外。
这个边界的价值不只是“支持多个模型”。它把 Provider 差异限制在 capability resolution、wire serialization、stream translation 与 error classification 四个面,同时让 Agent Loop 能用同一套 Turn/Step、request header 和 SessionEvent 语义验证每次派发。
1. 一次模型调用跨越六个提交边界
从日志派生历史
Loop 读取当前 request header 与 Session.deriveMessages(),不向 SDK 询问对话历史。
准备 exact call
prepareCall() 解析唯一 route、精确 model capability、adapter defaults、context window 与 retry policy。
提交 request facts
Canonical header 与 request context 先写 Session;最终 request 被 deep-freeze。
Durability barrier
llm/stream middleware 在构造下游 Adapter stream 前 flush 完整 request prefix。
记录 raw stream
每个 chunk 先 append 为 assistant/chunk,再交给规范组装器。
提交语义消息
只有成功 finish 才生成一个 assistant/message;失败由 request-error policy 处理。
同一 Step 内的 request、raw chunks 和最终 message 都携带相同 turn/step;Provider 不接触 Session 对象,也不决定何时开始下一 Step。LOOP-STEP-STATELOOP-REQUEST-BUILD
模型 I/O 是明确的语义副作用边界:request facts 未成功持久化时,checkpoint listener 不会调用 Adapter。LLM-CHECKPOINT-BARRIER
2. 内部协议刻意比任何单一 Provider 更窄
| 层 | 核心表示 | 关键约束 | 为什么重要 |
|---|---|---|---|
| Message | {id, role, content, source} | 创建时 clone + deep-freeze;stable ID 穿过投递、日志与 request | 没有 SDK-owned history 的第二事实源 LLM-MESSAGE-VALUE |
| Content | text / reasoning / image / tool-call / tool-result | Tool arguments 保留模型产生的原始 JSON string | 解析错误留给工具边界;Adapter 不重写原始意图 LLM-CONTENT-PROTOCOL |
| Provenance | user / plugin / model / tool | Assistant 必须记录 provider、model,可带 replay state | 跨 Provider 历史不会冒充 native response LLM-MESSAGE-IDENTITY |
| Stream | block-start / delta / block-end / usage / finish | Index 允许交错 block;usage 在 terminal finish 之前;finish 后无 chunk | UI、日志、Assembler 与 Adapter 共用一份流合同 LLM-STREAM-PROTOCOL |
| Request | config + system + tools + messages | Provider/model 精确路由;sessionId 与 purpose 是 model-hidden metadata | 会话语义与 wire metadata 分离 |
该协议是 merge-extensible,但内置 Adapter 对未知 block 的行为并不自动对称。扩展一个新 block type 必须同时补 Adapter、UI、compaction 和 replay 支持;仅扩 TypeScript union 不会获得端到端能力。
3. Provider route 是唯一映射,不是 fallback list
一个 provider string 同时只能映射到一个 Adapter registration。空 route、重复 route 或 metadata ID 不一致均同步失败;一项多 route 注册是 all-or-nothing。Effect teardown 精确删除自己仍拥有的 route,replace() 在一个同步区段内完成旧集合到新集合的切换。LLM-ADAPTER-REGISTRY
prepareCall() 捕获 registration、resolved config、retry policy、model context 和 adapter-default marker,返回只能 dispatch 一次的 frozen handle;调用者若改动 config,派发以 INVALID_PREPARED_CALL 拒绝。LLM-PREPARED-CALL
Route 在 prepare 后被替换,旧 prepared call 仍由旧 Adapter 和旧 retry policy 服务;新的 registry lookup 才看到新 registration。LLM-PREPARED-PIN-TEST
4. BlockAssembler 是成功响应的唯一语义折叠器
Assembler 以 block index 维护插入顺序,支持没有 start/end 的 delta-only Adapter,也允许 reasoning、text 与多个 tool call 交错。第一次 block-end 成为权威完整 block;随后同 index 的 delta 或再次 close 被忽略。LLM-BLOCK-ASSEMBLER
- 没有显式 finish 时 accessor 默认
stop;生产 Adapter 应自己保证 terminal protocol。 - 未闭合的 text/reasoning/tool-call 可从 delta 合成;未知未闭合 block type 会失败。
max-tokens时所有 tool-call block 被过滤,避免执行截断的 JSON 参数。- Usage 采用最后一个 usage chunk;replay state 来自 terminal finish。
- Raw chunks 已先进入 Session Log,因此语义消息不是唯一可观测层。
这是一处重要的“单一折叠算法”选择:Provider Adapter 负责发事实,Agent Loop 不各写一套拼接逻辑。代价是协议容忍 malformed stream 的若干分支;它优先保持已关闭 block 与已展示流一致,而不是对每种违规都 fail-fast。
5. DeepSeek 直连 Adapter:窄协议、宽 model passthrough
| 面 | 真实行为 | 边界/后果 |
|---|---|---|
| Route | 固定 deepseek-official | 默认 catalog 是 V4 Flash/Pro,但未知 model ID 仍原样直通 |
| Capability | 默认 1,000,000 context、256,000 output、text-only | Catalog 是 discovery/advisory,不是 allowlist LLM-DEEPSEEK-MODELS |
| Thinking | off/high/max;一般默认 high;deployment disabled 时只允许 off | session-title 无条件关闭 thinking |
| Configuration | 每次 request 解析 endpoint、credential 和 last-good snapshot | 坏的 live settings 不替换上一份可服务配置 LLM-DEEPSEEK-CONFIG |
| Transport | 原生 fetch POST /chat/completions + SSE | 发送 attribution、匿名 user ID、session ID;compaction 有单独 header LLM-DEEPSEEK-TRANSPORT |
System 成为首条 wire message;user text 连接;内部 user-role tool-result 被展开成独立 role: tool。Assistant 的 text 与 tool calls 原序投影,但 reasoning_content 只在 tool-call turn 回传;plain-turn reasoning 仍在 durable internal message 中,却不进入后续 DeepSeek wire history。Image 在任何 text flattening 前显式拒绝。LLM-DEEPSEEK-SERIALIZE
Translator 在收到字面量 [DONE] 时才关闭所有 block、发最后 usage 和 finish。坏 JSON 为 MALFORMED_RESPONSE,无 DONE 为 STREAM_CLOSED,没有任何 content 的正常 stop 为 EMPTY_RESPONSE。DeepSeek 的 prompt total 包含 cache hit,Adapter 会扣除 hit 形成互斥 input bucket。LLM-DEEPSEEK-TRANSLATE
6. Pi AI Adapter:宽 Provider 面、严格 exact-model 面
已安装 catalog 的 route 且未覆盖 wire API 时,Harness 复用原 pi-ai Provider,保留其 native API implementation 与 ambient auth。手工 route 或显式 protocol override 只允许 openai-completions、openai-responses、anthropic-messages;Bedrock、Vertex、Azure 与 OAuth-only 路径因当前配置形状无法完整表达而不伪装支持。LLM-PI-PROVIDER
Plugin 默认没有任何 route。每个配置 profile 自己拥有 model catalog/override、endpoint、credential reference、reasoning mapping、thinking budget、cache retention、SSE/WebSocket transport、timeout、header 与 retry policy。LLM-PI-CONFIG
| 调用行为 | Pi AI 路径 | 与直连 DeepSeek 的差异 |
|---|---|---|
| Model resolution | 必须在当前 profile 精确存在;未知 model 在 I/O 前失败 | DeepSeek 未列出的 ID 仍 passthrough |
| Stop sequences | 显式不支持 | DeepSeek 直接写 wire stop |
| Image | Model 声明 image 且 durable attachment service 存在才接纳 | DeepSeek 路径总是 text-only |
| Retries | 传给 SDK 的 maxRetries: 0 | 两条路径都把可见 attempt 交给 Agent recovery |
| Reasoning | 按 exact model 枚举,非法 effort 拒绝,不让 SDK clamp | DeepSeek 固定 off/high/max |
| Error detail | 上游库丢失 Error/cause 后以 message regex 分类 | 直连路径可保留 HTTP status、Retry-After 与 request ID |
Pi Adapter 在第一次 await 前固定 profile、model collection 和 descriptor,再解析 credential;中途配置更新不会改变已开始请求,下次调用看到新 snapshot。LLM-PI-STREAM
7. “Replay”有两层,不能混为一谈
Pi AI 的 text、reasoning 和 tool-call 内容仍以 Harness Message 为权威;PiAiReplayState v1 只持久化 API、source provider/model、response identity、stop reason 与各 block signature。恢复时先验证 kind/version,再验证 source 和每个 block 的数量、类型及 metadata。LLM-PI-REPLAY
LlmRuntime 只在历史 provider 与目标 provider 当前由同一个 Adapter instance 拥有时保留这份 private state;否则剥离 state,以普通“foreign assistant”重建。这防止一个 Adapter 读取另一个 Adapter 的私有协议数据。
dsh-llm-replay 是测试/快照 Adapter,不是生产会话恢复机制。它从 assistant/chunk 的每个 finish 派生一次 provider call;同一 Turn/Step 内的 retry 被切成多条 script entry。Thrown stream 与 hang 无法仅凭成功日志还原,必须提供 sidecar override。LLM-REPLAY-DERIVATION
会话 replay 重建“下一次请求”;Adapter replay state 恢复“历史响应的 provider-native metadata”;测试 replay 重放“预录的 chunk 流”。三者解决不同问题。
8. Retry 是同一 Step 的另一次 Provider attempt
step() 的 while (true) 包住 build request、stream 与 request-error waterfall。失败得到 {kind:'retry'} 后直接 continue,没有关闭 Step 或 Turn;下一 attempt 重新 derive messages 和 build request,但复用该 Step 的 prompt assembly。PROMPT-RETRY-BOUNDARYLLM-RETRY-SAME-STEP-TEST
Normal mode 默认最多两次 retry,只覆盖 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT;本地延迟从 500ms 指数增长至 10s,带 10% 对称 jitter。Always mode 对所有 request failure 无限尝试,直到成功、取消或 plugin disposal。LLM-RETRY-POLICY
Plugin 先 append 包含 policy key、失败、retry number 和 delay 的 llm/retry,再进行可取消等待;等待结束后 append llm/retry-started,才把 retry action 交回 Loop。Provider Retry-After 只有不超过 cap 才直接采用。LLM-RETRY-EXECUTOR
真实 mock HTTP 测试要求失败与成功 attempt 的 body 完全相同;失败 partial chunks 仍在 raw log,但不进入 surface,最终只提交一个 assistant message。干净 EOF 分类为 STREAM_CLOSED,当前默认 policy 不重试它。依赖未安装,本研究只审阅了测试源码。LLM-RETRY-WIRE-TEST
9. 上游 Retry 文档已经与实现漂移
llm-retry README 写着“每次 retry 打开新编号 Turn”“Loop 关闭 failed Turn 后再开 retry Turn”,并把 Agent Turn 称为唯一 retry boundary;当前生产源码和两套测试都证明 attempt 共享同一 turn/step。LLM-RETRY-DOC-DRIFTLOOP-REQUEST-ERROR-TEST
这不是措辞小错。它影响事件解释、UI grouping、成本归因、replay script 分段和所有以 (turn, step) 当作 request identity 的 projection。分析和下游实现必须以当前 loop 与测试为准,而不是复制 README 的“新 Turn”叙事。
10. Error normalization 只包住 Adapter-owned failure
Provider route 选择、Adapter stream() 调用、iterator 构造和 next() 抛错都会变成一个 terminal finish {error|aborted, failure}。Failure 是可序列化快照,可保留 Harness-owned code、HTTP status、provider delay 和 request ID。LLM-FAILURE-BOUNDARY
llm/streammiddleware 自己抛错:继续 throw,不伪装成 Provider failure。- 下游 consumer 在 yield 后抛错:继续 throw。
- Consumer 提前关闭时 Adapter iterator 的 cleanup failure:继续 throw。
- Adapter 抛任意非 Error 值:归一为
UNKNOWN,不让 hostile coercion 逃出。 - AbortSignal 已取消或 failure code 为 ABORTED:terminal reason 为 aborted。
该区分保留责任归属:Provider/transport failure 可以进入统一 retry policy;插件 bug、consumer bug 与 cleanup bug 不能被自动重试吞掉。Pi AI 因上游 flatten Error 而只能用文本分类,是 capability 退化,不是核心协议的要求。
11. Token、cache 与一个高置信度计费缺口
| 来源 | 映射 | 保真边界 |
|---|---|---|
| DeepSeek | input = prompt_tokens - cache_hit;output 原样;可带 reasoning | 没有 cache-write 字段;reasoning 是 output 的子维度,不另加总 |
| Pi AI | input/output/cacheRead/cacheWrite 映射;零 cache bucket 省略 | pi-ai 已把 reasoning 计入 output,不再单独上报 |
| Harness projection | 累计 uncached input、output、cache read/write | 以最新同 turn/step sample 替换前 sample |
Usage chunk 会早于最终 message;成功时 message 又携带同一 usage。因此 projection 用 (turn, step) 识别“同一次调用”的早期与最终样本,扣掉旧值再加入新值,避免双计。LLM-TOKEN-USAGE-PROJECTION
Retry 让多个 Provider attempt 共享同一 (turn, step),而 replay 测试明确把这些 attempt 当作多个 call。若失败 attempt 已发 usage,成功 attempt 的 usage 会替换而非累加它;累计投影因此低估真实 Provider 消耗。LLM-MULTI-ATTEMPT-REPLAY-TESTLLM-RETRY-SAME-STEP-TEST
12. Prepared registration 并未固定 Adapter 的动态配置代际
prepareCall() 固定 Adapter registration 与 resolved model config;随后 checkpoint 可能异步 flush;真正开始迭代时,DeepSeek Adapter 再调用 options(),Pi Adapter 再调用 current()。若 settings 恰在两者之间更新,日志中的 model defaults/context 可能来自代际 A,而 endpoint、profile 或 credential 来自代际 B。LLM-PREPARED-CALLLLM-CHECKPOINT-BARRIERLLM-DEEPSEEK-TRANSPORTLLM-PI-STREAM
现有 Pi snapshot 测试覆盖的是 Adapter stream 已开始 后,credential await 期间发生更新;它证明开始后的 snapshot 稳定,却没有覆盖 prepare 与首次 iterator 之间的窗口。更进一步,Loop reconstruction invariant 比较 model/system/temperature/maxTokens/stop/tools/messages,但没有比较 provider 或 reasoningEffort。LLM-REQUEST-INVARIANT-SCOPE
13. 两条生产 Adapter 的能力矩阵
| 维度 | DeepSeek direct | Pi AI | 架构含义 |
|---|---|---|---|
| Provider breadth | 一个官方 route | 多 profile、多 catalog provider | 窄而保真 vs 宽而受上游抽象约束 |
| Wire control | 完整 fetch/SSE ownership | 委托 pi-ai provider APIs | 直连能保留更多错误与 header 事实 |
| Model admission | Catalog 外 passthrough | Exact catalog membership | Forward compatibility vs 配置闭包 |
| Images | 拒绝 | 按 capability + attachment store | Durable binary ownership是额外依赖 |
| Reasoning | 固定 off/high/max | 每 model 映射 | Provider-neutral ID 仍需要 Adapter 验证 |
| History fidelity | Tool turn passback;plain reasoning 不回传 | 版本化 signatures/replay metadata | 同一内部 Message 可有不同 wire 保真度 |
| Usage | 含 cache read 与可选 reasoning | 含 cache read/write,reasoning folded | 统一 bucket 不意味着源数据同等丰富 |
| Retry | Adapter 每次只尝试一次;Agent recovery 拥有可见 retry | 统一成本、事件和取消边界 | |
14. 优势、代价与当前未知
| 选择 | 收益 | 代价/未知 |
|---|---|---|
| Provider-neutral immutable messages | 日志、UI、replay 与 request 共用事实 | 每个新 block 必须跨所有消费者落地 |
| Raw chunks + semantic message 双层日志 | 兼顾流式观察与最终语义 | Projection 必须正确识别 attempt 与 replacement |
| Prepared one-shot call | 防 registration HMR 与 request config drift | 尚未封闭 Adapter-internal config generation |
| Retry 位于 Agent Loop | 失败 attempt、等待、取消和成本可记录 | Always mode可无限消耗;同 Step identity 已暴露计费缺口 |
| Direct + library-backed 双 Adapter | 同时拥有一条高保真路径和广覆盖路径 | 错误细节、reasoning、usage、stop/image capability 不对称 |
| 没有隐式 failover | Provider 切换不会悄悄改变语义或成本 | 需要部署显式组合自己的 fallback policy |
- 尚未验证安装版 pi-ai 0.82.1 的完整内置 provider/model catalog;依赖目录缺失。
- 尚未用真实 API 比较 DeepSeek 与 Pi 路径在同一 model 上的 reasoning/cache/error fidelity。
- 尚未证明 dynamic-config generation window 能在真实 settings HMR 时稳定复现。
- 尚未有自动 provider failover 的标准事件、幂等或成本协议。
- 上游 Retry 文档需要以同 Step attempt 语义修正。
本章逐段审阅了生产源码、类型与测试源码,并把测试结论明确标为“测试定义的合同”。仓库没有安装依赖,本研究没有执行上游测试,也没有把 source inference 冒充运行时观察。
我的学习体会
内容仅自动保存到当前浏览器,不上传、不进入仓库。你可以导出 Markdown 自行归档。