跳到主要内容

数据截至 (上游 commit 7538cc96774b)

模型接入层:一个请求怎么发出去,以及订阅引擎这条岔路

30 秒导读: 02 讲的 loop 每一步都要问模型一次。这一章讲那"一次"到底怎么落地——TurnItem 数组怎么变成一个 HTTP body、SSE 字节流怎么变回结构化的 item;以及当用户选了 Claude 订阅时,kun 怎么不跑自己的 loop,改把整轮交给官方 Agent SDK,再把 SDK 的输出翻译回自己的事件契约。


1. 这一章解决的问题(零基础也能懂)

agent 的每一步都是同一个动作:把"到目前为止发生了什么"打包发给模型,听模型说下一步干什么。

听起来只是一次 fetch。真做起来,这一层要同时解决四件事:

问题白话谁负责
供应商说不同的方言同一个"发消息 + 带工具"的意思,OpenAI、Anthropic、各家中转各写各的 JSONCompatModelClient
回来的是字节流不是对象模型边想边吐,工具参数是一个字符一个字符拼出来的SSE 解析 + 增量累积
网络和供应商都不可靠502、429、流卡死、参数被截断成半个 JSON重试 / 空闲超时 / 参数修复
有些用户根本不想按 token 付费手里有 Claude Pro/Max 订阅,想用订阅额度跑订阅引擎(Agent SDK)

一句话直觉:这一层是 kun 的"出口海关"。 里面流通的是 kun 自己的 TurnItem;出关要换成对方国家的护照(三种线上协议之一);入关要把对方的东西再换回 TurnItem。而订阅引擎那条路,相当于直接借别人的车队跑一趟,回来把行程单翻译成自己的账本


2. 顶层全景:一个端口,两条出口

kun 的 loop 只认识一个接口 ModelClient(kun/src/ports/model-client.ts:113),它只有三个成员:providermodelstream(request)。loop 不知道下面接的是 HTTP 还是别的东西。

真正的分叉发生在两个层次:

AgentLoop.runTurn
|
┌───────────────┴────────────────┐
│ ① 线程 provider 是 agent-sdk? │ kun/src/loop/agent-loop-turn-lifecycle.ts:88-97
└───────┬───────────────┬────────┘
是 否
│ │
┌───────────▼──────┐ ┌────▼──────────────────┐
│ 订阅引擎 │ │ MultiProviderModelClient│ 按 providerId 选客户端
│ SDK 拥有 loop │ └────┬──────────────────┘
│ kun 注入大脑 │ │
└───────────┬──────┘ ┌────▼──────────────────┐
│ │ CompatModelClient │ 自己拼 HTTP / 解 SSE
│ └────┬──────────────────┘
└───────┬───────┘

同一份 kun 运行时事件

怎么读这张图:从上往下是"一个 turn 的出口选择",两条路最后汇到同一种事件——GUI 完全看不出这一轮是自己发的 HTTP 还是 SDK 跑的。

部件职责:

部件干什么在哪个文件
ModelClient唯一的出口端口,loop 只依赖它kun/src/ports/model-client.ts:113
MultiProviderModelClient按请求上的 providerId 挑一个 HTTP 客户端,挑不到就用默认的kun/src/adapters/model/multi-provider-model-client.ts:16
CompatModelClient主力 HTTP 客户端:拼 body、发请求、解流、算钱kun/src/adapters/model/compat-model-client.ts:33
ModelEndpointFormat三种线上形态的枚举与路径推导kun/src/contracts/model-endpoint-format.ts:1
AgentSdkRuntime订阅引擎:把整轮委托给官方 Agent SDKkun/src/runtime/agent-sdk/agent-sdk-runtime-core.ts:56
SdkEventMapper把 SDK 消息流反投影成 kun 运行时事件kun/src/runtime/agent-sdk/sdk-event-mapper.ts:113

装配点分散在 runtime-composition 系列:kun/src/server/runtime-factory-model.ts:127 造默认客户端;kind: 'agent-sdk' 的 provider 不造 HTTP 客户端,由 agentSdkProviderIdsForOptions(runtime-factory-model.ts:303)记进名单;各 HTTP 客户端包进 MultiProviderModelClient(kun/src/server/runtime-composition-model.ts:228);订阅引擎在 kun/src/server/runtime-composition-agent.ts:216 构造。


3. 协议层:端口长什么样

3.1 请求:ModelRequest 是一份"这一轮的全部原料"

ModelRequest(kun/src/ports/model-client.ts:22)不是一个 messages 数组,而是分好层的原料,让适配器自己决定怎么摆:

字段是什么为什么单列出来
systemPrompt字节稳定的系统提示必须逐字节不变,否则缓存前缀作废(见 03)
modeInstruction模式说明(如 Plan 模式)紧跟 systemPrompt 之后,不污染前缀本身
prefix / history不可变前缀 + 会话历史前缀在前、历史在后,顺序即缓存边界
contextInstructions每轮都变的动态指令(目标预算、待办、记忆)必须排在历史之后,否则每次计数器一动就打穿整段缓存
tools本轮广告出去的工具目录04
providerId可选的 provider 覆盖让 workflow / 定时任务在同一个 kun 进程里换供应商
abortSignal中断信号用户点停、空闲超时都走它

3.2 响应:ModelStreamChunk 只有七种

kun/src/ports/model-client.ts:9-16 定义了适配器唯一被允许吐出的东西:

chunk含义
assistant_text_delta正文增量
assistant_reasoning_delta思维链增量
tool_call_delta工具调用参数的增量字符串
tool_call_complete一个工具调用拼完了,参数已是对象
usagetoken / 缓存 / 成本快照
completed本次响应结束,带 stopReason
error出错了,带可选 code

这七种就是全部契约。 不论下面是 chat completions 的 delta.tool_calls、Responses 的 response.function_call_arguments.delta、还是 Anthropic 的 input_json_delta,到了 loop 眼里都长一个样。

3.3 三种线上形态,加一个"你自己填全路径"

kun/src/contracts/model-endpoint-format.ts:1 只有四个值:

形态请求体长相路径后缀
chat_completions{ model, messages, tools:[{type:'function',...}] }chat/completions
responses{ model, input, tools:[{type:'function', name, ...}] }responses
messages{ model, system, messages, tools:[{name, input_schema}] }messages
custom_endpoint不是第四种协议,是"baseUrl 就是完整 URL"原样使用

custom_endpoint 是个巧妙的偷懒:很多中转站的地址不是标准的 /v1/...(例如智谱 Coding Plan 的 https://open.bigmodel.cn/api/coding/paas/v4/chat/completions)。这时用户直接把完整 URL 填进 baseUrl,resolveModelEndpointFormat(kun/src/contracts/model-endpoint-format.ts:71)再从 URL 尾巴反推真实协议:

// 示意,非源码 —— inferModelEndpointFormatFromUrl 的核心判断
if (path.endsWith('/chat/completions') || path.endsWith('/completions')) return 'chat_completions'
if (path.endsWith('/responses')) return 'responses'
if (path.endsWith('/messages')) return 'messages'
return null // 反推不出来 → 直接报错,不猜

反推失败会在 streamInner 开头就吐 error 并附上"必须以 /chat/completions、/completions、/responses 或 /messages 结尾"的原话(compat-model-client.ts:62-69)。真实实现见 model-endpoint-format.ts:62inferModelEndpointFormatFromUrl

URL 拼装另有一段针对现实的补丁:buildModelEndpointUrl(kun/src/adapters/model/compat-model-support.ts:44)会识别 baseUrl 结尾是 /beta(DeepSeek 的 beta 域)或已经带了 /v1/v2,避免拼出 /v1/v1/chat/completions 这种废 URL。

3.4 协议还能按"单个模型"覆盖

endpointFormatForModel(kun/src/adapters/model/compat-model-client-base.ts:93)先问模型的能力元数据要 endpointFormat,再退回 provider 级配置。注释点明了动机:同一个供应商(如 OpenCode Go)可能一部分模型走 chat completions、另一部分走 Anthropic Messages。协议选择因此是 per-model 的,不是 per-provider 的。


4. 出站:一次请求怎么被拼出来

4.1 消息顺序就是缓存策略

collectMessages(kun/src/adapters/model/compat-model-client-base.ts:281)现在是个薄壳,真正的分段在 CompatMessageProjector.project(kun/src/adapters/model/compat-message-projector.ts:39-73),顺序本身是设计:

① systemPrompt (字节稳定,缓存锚点)
② threadProfileInstruction (线程 persona,仍是 system)
③ modeInstruction (模式说明,仍在前段)
④ prefix + history → repairModelHistoryItems() → itemsToMessages()
⑤ contextInstructions (每轮都变的:目标/待办/记忆/技能)
⑥ 附件挂到最后一条 user 消息,最后 healToolMessagePairs + normalizeThinkingAssistantMessages

第 ⑤ 段的位置是本层最值得记住的一条经验。源码注释直白写着:请求级上下文刻意按时序排在历史之后,不进供应商稳定前缀(compat-message-projector.ts:57-60)——它每轮都变,一旦放在历史之前,整段对话的供应商前缀缓存每一步都作废。(顺带:目标指令里那个"已用 token"计数器如今干脆不存在了——用量被刻意移出指令以保住缓存,见 02 §7.2;"压到历史尾巴"这条纪律对剩下的易变内容依然成立。)

Anthropic 形态下同一条规则再实现一遍:messagesToAnthropic(kun/src/adapters/model/compat-request-builder.ts:109)发现一条 system 消息出现在已有对话之后,就不往顶层 system 块里塞,而是用 appendTrailingInstruction(:208)挂进最后一个 user 轮次里(:119-131)。

4.2 同一份"历史修复"服务两个目的

第 ③ 段调用的 repairModelHistoryItems(kun/src/domain/model-history-repair.ts:11)是 03 里为缓存服务的那份修复函数,这里被原封不动复用为 400 防御

原因是同一条事实:kun 的 TurnItem 里混着 GUI 才关心的东西(审批、用户输入、思维块),而供应商 API 严格得多——每个 assistant 的 tool_call 块后面必须紧跟数量一致的 tool_result。修复函数把不合规的组合剔掉,缓存一致性和"不吃 400"于是是同一件事的两面。

出站路径上还叠了两道同构的保险:

  • toolCallBlockToMessages(kun/src/adapters/model/compat-message-projector.ts:135)把连续的多个 tool_call 折成一条 assistant 消息 + N 条 tool 消息;只要有一个 callId 没配到结果,整块返回 null 被丢弃(:193-195)。
  • healToolMessagePairs(:438)在消息层面再扫一遍:孤儿 role:'tool' 消息直接丢,配不齐的 assistant 工具消息连同结果一起丢。

为什么要做两遍? 一遍在 item 层(懂 kun 的语义,知道哪些是"桥接项"),一遍在 message 层(懂线上协议的硬约束)。任一层单独都堵不死。

4.3 三种 body 的分岔

buildRequestBody(kun/src/adapters/model/compat-model-client-base.ts:249)把消息拼好后交给 CompatRequestCodecs.build(kun/src/adapters/model/compat-request-codecs.ts:101)分三路:

形态入口关键差异
chat completionschatCompletions compat-request-codecs.ts:110stream_options:{include_usage:true} 才拿得到 usage(:121)
responsescase 'responses' :103input 而非 messages;max_output_tokens;工具是扁平的 {type,name,...}
messagescase 'messages' :105,body 在 :261-290max_tokens 必填;system 单独成块并打 cache_control

Anthropic 那一路藏着一个真实教训。DEFAULT_MESSAGES_MAX_TOKENS = 8192DEFAULT_MESSAGES_REASONING_MAX_TOKENS = 32_768(compat-request-codecs.ts:89-90)写明:思考 token 和输出 token 共用同一份预算,旧的 4096 默认值会让模型想完之后没剩几个 token,把工具调用的参数截断成非法 JSON。所以开了 thinking 就换更大的默认上限(:266-268)。

4.4 显式缓存断点

Anthropic 系协议的缓存是显式的:只有 cache_control 断点之前的内容才进缓存,而且每请求最多 4 个。applyAnthropicCacheControl(kun/src/adapters/model/compat-request-builder.ts:233)的做法很省:

// 示意,非源码 —— 从后往前打两个断点
let breakpoints = 0
for (let i = messages.length - 1; i >= 0 && breakpoints < 2; i -= 1) {
const content = messages[i].content
if (typeof content === 'string' || content.length === 0) continue
content[content.length - 1].cache_control = { type: 'ephemeral' } // 打在最后一个块上
breakpoints += 1
}

加上 system 块自带的那一个(kun/src/adapters/model/compat-request-codecs.ts:275-277),一共三个断点:一个盖住 system + 工具定义,两个盖住最近两条消息——于是下一步的请求正好能命中上一步写下的前缀缓存

4.5 思考模式:一个字段翻译成五种方言

reasoningEffort 从 loop 传下来时只是 'off' | 'low' | ... | 'max' 这样的抽象档位。落到线上要看模型能力元数据里的 requestProtocol:

requestProtocol落成什么实现
deepseek-chat-completionsreasoning_effort +(仅官方 host)thinking:{type}kun/src/adapters/model/compat-request-reasoning.ts:176
glm-chat-completionsGLM 自己的 thinking 开关:193
mimo-chat-completions小米 MiMo 的写法:205
openai-responsesbody.reasoning(走 responsesReasoningForEffort):7
anthropic-thinkingthinking 块 + 输出预算联动:220
none什么都不加applyReasoningEffort :40 的默认支

有一条防御值得单拎出来。requiresReasoningRoundTrip(:314)的注释指出:thinking 字段是 DeepSeek 私有扩展,第三方 OpenAI 兼容中转(SiliconFlow、OpenRouter、llama.cpp)看到它会 400 或返回空(issue #26)。所以自动开启只在官方 DeepSeek host 上发生(isDeepSeekHost,kun/src/adapters/model/model-error-probe.ts:7);用户显式选档位才强制走这条路。

Azure 同理:isAzureOpenAiEndpoint(compat-request-reasoning.ts:298)命中就把 thinking 整个关掉(生效处在 compat-request-codecs.ts:124includeThinking 判定)。

开了思考模式还有一个副作用:DeepSeek 协议要求每条 assistant 消息都带 reasoning_contentnormalizeThinkingAssistantMessages(kun/src/adapters/model/compat-message-projector.ts:418)给缺失的补一个空格 ' '——因为空字符串会被判非法(reasoningContentOrSpace,:385)。这类"补一个空格"的细节是兼容层的日常。


5. 入站:字节流怎么变回 item

5.1 SSE 循环的骨架

streamSse(kun/src/adapters/model/compat-model-client-stream.ts:335)是一个手写的 SSE 解析器,不依赖任何库:

读一块字节 ──► 拼进 buffer ──► 找 "\n\n" 帧边界

┌──────────────┴──────────────┐
data: [DONE] ? JSON.parse
│ │
收尾退出 consumeStreamPayload(按协议分派)

累积 pending 工具参数 / 产出 chunk

三个细节:

  1. 帧边界只认 \n\n,一帧里所有 data: 行拼接后再解析(:390 起)——多行 data 的 SSE 也吃得下。
  2. JSON 解析失败直接跳过(:398 一带),不让一个坏帧毁掉整轮。
  3. 每读一块都过一次看门狗(下一节)。

5.2 空闲超时:比总超时更实用的那种

readStreamChunk(kun/src/adapters/model/compat-model-support.ts:330)把三件事塞进一个 Promise.race:读到数据、被 abort、空闲计时器到点

// 示意,非源码 —— 三选一的看门狗
const result = await Promise.race([
reader.read().then(r => ({ kind: 'chunk', ...r })),
abortPromise, // 用户点了停
new Promise(res => setTimeout(() => res({ kind: 'timeout' }), idleTimeoutMs))
])
if (result.kind === 'timeout') await reader.cancel('model stream idle timeout')

默认 450 秒(DEFAULT_STREAM_IDLE_TIMEOUT_MS,compat-model-support.ts:12),可由运行时配置覆盖(normalizeStreamIdleTimeoutMs,:187)。

妙在计的是"两块数据之间的间隔",不是整轮总时长。 一个思考 5 分钟但持续吐 token 的模型不会被误杀;一个 TCP 连着但再也不说话的僵死连接会在到点后被判死,并吐出带 code: 'stream_idle_timeout' 的 error(compat-model-client-stream.ts:372-380)。

5.3 工具调用增量:三种协议,一张同构状态表

三种协议吐工具调用的方式完全不同,但客户端用同一组状态容器接住:pendingArguments: Map<callId, {index, name, arguments}>pendingByIndex: Map<index, callId>

阶段chat completionsOpenAI ResponsesAnthropic Messages
声明调用delta.tool_calls[].function.nameitem.type === 'function_call'content_block_starttype:'tool_use'
参数增量function.arguments 字符串片段response.function_call_arguments.deltainput_json_delta.partial_json
宣告完成finish_reason === 'tool_calls'(整批一起)response.output_item.done(逐个)content_block_stop(逐个)
实现位置chat-completions-stream-decoder.tsresponses-stream-decoder.tsanthropic-messages-stream-decoder.ts

最麻烦的是 callId 认领。有的供应商第一帧不给 id 只给 index,有的中途才补上真 id。三个解码器如今共用同一个认领函数 resolvePendingToolCall(kun/src/adapters/model/tool-call-stream-identity.ts:18):

  • 认领顺序:显式 id → 按 index 查 pendingByIndex → "只有一个待定就是它" → 都没有就合成一个 __kun_stream_tool_call_index_<index>(tool-call-stream-identity.ts:25-35:92-98)。
  • 真 id 中途到货时,migratePendingCallId(:59-76)把之前按 index 建的临时条目迁移到真 id 下,不丢已累积的参数;若迟到的真 id 撞上另一个 pending 调用,直接抛 ModelStreamProtocolError 而不是猜。
  • 畸形 id 不再静默兜底:null/空串视为"没给"(继续走 index 或唯一待定认领),非字符串、带控制字符、超长的 id 直接抛 ModelStreamProtocolError(:78-90)。

5.4 收尾兜底:宁可给个坏参数,也不能凭空吞掉一次调用

流结束后有一段"安全网"(kun/src/adapters/model/compat-model-client-stream.ts:494-520),注释写得很清楚:chat completions 分支只在 finish_reason === 'tool_calls' 时结算工具调用;如果供应商用 stoplength 或干脆一个裸 [DONE] 收尾,而参数还挂在 pending 里,这次调用就静悄悄消失了

于是收尾时强制 flush 所有有名字、未结算的 pending;并且只要 flush 出过东西,就把 stopReasonstop 纠正成 tool_calls(:535-546)——"供应商标错了 stop reason"这件事在这里被当作已知事实处理。

5.5 参数修复:半个 JSON 也要救回来

parseToolArguments(kun/src/adapters/model/compat-model-client-base.ts:315)转手给 repairToolArguments(kun/src/adapters/model/tool-argument-repair.ts:6),四级降级:

直接 JSON.parse
└─失败→ 剥掉 ```json 代码围栏
└─失败→ 括号配平提取第一个 {...}
└─失败→ 括号配平提取第一个 [...]
└─全失败→ { __raw: 原始字符串 }

括号配平那步(extractBalanced,:71)会正确跳过字符串里的括号与转义(:79-91),不是幼稚的 indexOf('}')

最后一档 { __raw }(:29)是刻意的:它不是"修好了",而是把一个模型能看懂的错误递给工具层。工具执行失败 → 报错回到模型 → 模型重试。比抛异常炸掉整轮温和得多。

5.6 usage 与算钱:两套 token 语义

mapUsage(kun/src/adapters/model/compat-model-client-base.ts:306)转手给 normalizeCompatUsage(kun/src/adapters/model/compat-usage-normalizer.ts:6),最核心的一段判定解释了一个容易算错的差异:

  • OpenAI 系:prompt_tokens总数,缓存命中的那部分在 prompt_tokens_details.cached_tokens 里另标。
  • Anthropic 系:input_tokens 不含缓存读写,真实提示大小要 input + cache_read + cache_creation

代码用 anthropicUsage 这个判定(compat-usage-normalizer.ts:29-32)区分两套语义再统一成 UsageSnapshot(kun/src/contracts/usage.ts:11)。搞错了会让缓存命中率显示成假的。

成本估算是"供应商报了就用报的,没报才自己算"(同一文件内),自算走 estimateDeepseekCost(deepseek-pricing.ts:80,分 hit/miss 两档单价)或 estimateMiniMaxCost(minimax-pricing.ts:116,分 input/cacheRead/cacheWrite/output 四档,并有 512K 长上下文加价阈值)。


6. 韧性:重试、错误分类与代理

6.1 两种性质完全不同的重试

场景触发条件做法实现
网关抖动配置的状态码(默认 [429, 503],kun/src/config/kun-config-runtime.ts:61-66)指数退避重发同一 body,默认最多 5 次kun/src/adapters/model/compat-model-client.ts:161-174,退避在 compat-retry-policy.ts
参数不被支持400/422 且报文里提到 stream_options/include_usage去掉 stream_options 重发一次shouldRetryWithoutStreamUsage compat-model-support.ts:172,分支 compat-model-client.ts:262-272

第一种能安全重发的理由写在注释里:此时还没有任何响应体被流出去,重发是幂等的。退避期间被 abort 会立刻中止(sleepWithAbort,compat-retry-policy.ts:56)。

第二种是典型的"探测式兼容":很多兼容中转不认 stream_options。与其在配置里让用户勾一个"你的供应商支持 include_usage 吗",不如先试一次,被拒了就降级重发——代价只有一次失败请求,收益是零配置。

6.2 错误分类:给用户可执行的下一步

classifyHttpError(薄壳在 kun/src/adapters/model/compat-model-client-base.ts:218,实现 classifyCompatHttpErrorkun/src/adapters/model/compat-http-diagnostics.ts:28)不是简单地把状态码丢出来:

状态附加信息code
404加一句"检查 Base URL 与 Endpoint format"http_404
429标为限流rate_limited
5xx + DeepSeek host另发一个探测请求看端点是否还活着deepseek_http_5xx / deepseek_unreachable

探测逻辑在 probeDeepSeekReachable(model-error-probe.ts:16):把 baseUrl 尾部的 beta/vN 段剥掉再拼 /v1/models 去 GET(:41-56)。区分"DeepSeek 挂了"和"你的网络到不了 DeepSeek"——这两种情况用户要做的事完全不同。

日志侧有对应的卫生要求:logHttpFailure(compat-model-client-base.ts:226)打日志前把 URL 过一遍 redactUrlForLog(compat-http-diagnostics.ts:132),query 里带 key/token/secret/signature/auth/password 的参数一律替换成 [redacted];响应体过 summarizeForLog(:152)截到 1000 字。

6.3 代理只包模型请求

createProxyFetch(kun/src/adapters/model/proxy-fetch.ts:6)在配了 modelProxyUrl 时返回一个替代 fetch,内部用 node:http/node:https + ProxyAgent 手工发请求,再把 Node 的响应流 Readable.toWeb 成 Web ReadableStream 包进 Response(:46-51)——这样上面的 SSE 解析代码一行都不用改

装配顺序是 config.fetchImpl ?? createProxyFetch(...) ?? fetch(kun/src/adapters/model/compat-model-client-base.ts:77):测试注入优先,其次代理,最后全局。

一个体贴的细节:发请求捕获异常时会判断这是不是 AbortError,只有真的传输失败才追加"去检查代理设置"的提示(compat-model-client-base.ts:166-185)。用户自己点了停,不该被引去排查一个好好的代理。


7. 能力元数据与多模态

7.1 ModelCapabilityMetadata 是这一层的"查表依据"

kun/src/contracts/capabilities-core.ts:55 定义的这份元数据,被 CompatModelClient 当成一个 (model) => metadata 的解析函数注入(CompatModelClientConfig.modelCapabilities,kun/src/adapters/model/compat-model-types.ts:43),支撑四个决策:

字段用在哪引用
inputModalitiesimage决定工具结果里的图片是真发还是降级成文字modelSupportsImageInput compat-model-client-base.ts:301
maxOutputTokens覆盖输出预算默认值resolveMaxTokens compat-model-client-base.ts:118
reasoning.requestProtocol选思考字段的方言applyProfileReasoningEffort compat-request-reasoning.ts:108
endpointFormatper-model 协议覆盖endpointFormatForModel compat-model-client-base.ts:93

7.2 图片进来的两条路

用户贴一张图,loop 的 resolveTurnAttachments(kun/src/loop/turn-attachment-service.ts:31)先查模型支不支持图片输入:

attachmentIds

┌────────────┴────────────┐
支持 image 输入? 不支持
│ │
ModelInputAttachment buildTextAttachmentFallback
(真 base64 图片块) (压缩后的 base64 + 元信息,当纯文本发)
│ │
attachImagesToLatest… attachTextFallbacksToLatest…
└────────────┬────────────┘
最后一条 user 消息

降级那一支会检查压缩后的 base64 是否超出策略上限,超了就报错而不是硬发(turn-attachment-service.ts:86:373-391)。渲染格式见 formatAttachmentTextFallback(kun/src/adapters/model/compat-message-projector.ts:600),它把图片包成一个带元信息的文本块:

[Attached image as base64 text]
Name: … FilePath: … MIME: … Dimensions: 1024x768 Bytes: …
Base64:
```base64
<很长的 base64>
```
[/Attached image]

为什么连不支持视觉的模型也要发? 因为 agent 手里有工具:模型看不懂 base64,但它能看到 FilePath,于是可以让工具去读那个文件。附件的取用受 AttachmentStore.resolveContent 的作用域校验保护(kun/src/attachments/attachment-store.ts:106,授权判定在 :166),图片类型由文件头嗅探而非扩展名(detectImage,:186)。

7.3 工具结果里的图片:两种协议两种摆法

工具(比如截图、图像生成)返回的图片走另一条路 toolResultToMessage(kun/src/adapters/model/compat-message-projector.ts:249):

  • 模型不支持视觉 → 只发文本,base64 直接丢掉,并写明"(image omitted…)"(:261)。base64 对纯文本模型毫无价值,发过去只是烧钱。
  • OpenAI 系splitToolImageMessagesForOpenAi(kun/src/adapters/model/compat-request-builder.ts:266,挂接点 :43)把 tool 消息拆成"纯文本 tool 消息" + "紧随其后的合成 user 消息带图"。因为 OpenAI 的 tool 消息不接受图片块
  • Anthropic 系 → 图片作为 tool_result兄弟块挂在同一条 user 消息里(compat-request-builder.ts:135-155)。注释说明为什么不用官方更新的"图片放进 tool_result 内部"的写法:第三方 Anthropic 兼容中转(MiniMax 等)大多没实现新形状,会返 502/4xx(:137-142)。

Anthropic 侧还有一条并行工具调用的硬约束:同一个 assistant 轮次的 N 个 tool_use,必须由同一条 user 消息里的 N 个 tool_result回答,拆成 N 条 user 消息会触发 "tool_use ids were found without tool_result blocks immediately after"。折叠逻辑就在 messagesToAnthropic 里(compat-request-builder.ts:109 起),判据是"真实用户轮次绝不会带 tool_result 块"。

7.4 图生图:把附件路径告诉模型

如果本轮工具目录里有 generate_image,imageGenerationReferenceInstructions(kun/src/loop/turn-attachment-service.ts:328)会额外注入一段指令,把每张图的工作区相对路径列出来,并告诉模型"改图/重绘时把这些路径填进 reference_image_paths"。路径计算会拒绝逃逸出工作区的相对路径(workspaceRelativeAttachmentPath,:407)。


8. 岔路:订阅引擎(这一章最值得看的一段)

8.1 它到底换掉了什么

前面七节讲的是"kun 拥有 loop,模型只是被查询的对象"。订阅引擎把这句话整个反过来

原生路径订阅引擎路径
谁跑循环kun 的 AgentLoop官方 Claude Agent SDK(内部是 Claude Code 二进制)
谁执行工具kun 的 tool hostSDK;kun 的工具经 in-process MCP 桥接回来
谁管上下文kun(缓存前缀 + 压缩)SDK;kun 每轮把历史当文本重放
谁付钱按 token 计费的 API key用户的 Claude Pro/Max 订阅额度
事件长什么样kun 运行时事件仍是 kun 运行时事件(反投影而来)

分叉在 runTurn 的最前面(kun/src/loop/agent-loop-turn-lifecycle.ts:88-97):

const sdkRuntime = this.opts.sdkRuntime
if (sdkRuntime) {
const providerId = (await this.opts.threadStore.get(threadId))?.providerId
if (sdkRuntime.handlesProvider(providerId)) {
return sdkRuntime.runTurn(threadId, turnId, signal) // 整轮交出去
}
}

handlesProvider(kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-turn.ts:129)的判定有两档:线程的 provider 在 agentSdkProviderIds 里,或者运行时默认 provider 本身就是 agent-sdk(此时接管所有没指定 HTTP provider 的轮次)。

8.2 全景:注入大脑,反投影输出

kun 侧 SDK 侧
┌──────────────┐
│ 历史转录 │──┐
│ 每轮指令块 │ ├─► composeSdkPromptText ──► sdk.query({prompt, options})
│ 用户这句话 │──┘ │
└──────────────┘ │
┌──────────────┐ │
│ persona │──► systemPrompt(preset+append) │ SDK 自己跑
│ kun 独有工具 │──► in-process MCP server "kun" │ 完整 loop
│ 审批策略 │──► canUseTool 回调 │
└──────────────┘ ▼
SdkEventMapper ◄── SDK 消息流


kun 运行时事件(GUI 无感)

怎么读:左边三组是"注入"(kun 的大脑),右下角一条是"反投影"(SDK 的输出翻回 kun 的语言)。 编排全在 AgentSdkRuntime.runTurn(kun/src/runtime/agent-sdk/agent-sdk-runtime-core.ts:68)。

8.3 注入之一:两档续接——能 resume 就 resume,不能就重放转录

这是整段设计里最反直觉、也最值得学的一处。原则只有一条:canonical history 永远归 kun 所有。在这个原则下,SDK 的会话续接机制(resume: sessionId)从"刻意不用"改成了"能用就用、兜底重放"的两档:

触发做法
resumed上一 turn 结算时提交了会话检查点(sessionCoordinator.commit,kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-lifecycle.ts:181-196),拿到 nativeSessionId 且本轮没有要注入的指令块sdk.query({ resume: sessionId })(kun/src/runtime/agent-sdk/agent-sdk-runtime-core.ts:253:288),SDK 自己接上旧会话
rebased没有可续接的原生会话(重启后、切换 provider、检查点缺失或指令块变了)把 kun 的历史渲染成文本转录重新发过去(:297-303)

每一轮都会记一条 delegated_runtime 事件,标明这一档是 resumed 还是 rebased 及原因(:305-316)——续接失败不会被掩盖。检查点提交本身的失败也被刻意降级为"下一次强制 rebase",绝不把已成功落盘的 kun turn 拖成失败(agent-sdk-runtime-factory-lifecycle.ts:191-196 的注释)。

重放那条路上,buildHistoryTranscript(kun/src/runtime/agent-sdk/sdk-context-assembler.ts:25)取出"不属于当前 turn"的全部 item,交给 buildSessionTranscript 渲染,默认封顶 48KB(DEFAULT_SDK_HISTORY_TRANSCRIPT_MAX_BYTES,:18)。

composeSdkPromptText(:50)再按"上下文 → 操作指令 → 当前请求"的顺序拼:

Earlier conversation in this thread (context — continue it; do not restart):
<prior_conversation>
…转录…
</prior_conversation>

…每轮指令块(计划模式/目标/待办/记忆/技能)…

Current request:
…用户这句话…

注意 :66-68 的收敛:如果既没有历史也没有指令块,整个 prompt 塌缩成裸的用户文本——首轮因此和"直接用 Claude Code"字节一致,SDK 侧的 prompt 缓存友好。

为什么要保留 rebase 这条路:SDK 的原生会话运行时一重启就没了,用户中途换 provider(比如从 DeepSeek 切到 Claude 订阅)时 SDK 那边也根本没有前半段对话kun 换来的是"跨重启、跨 provider 切换都不丢上下文",这在一个可以随时改模型的 GUI 里是必需品;resume 只是在这条底线上的加速器。

8.4 注入之二:只桥接 kun 独有的工具

selectBridgeableTools(kun/src/runtime/agent-sdk/sdk-tool-bridge.ts:73)按两张名单过滤:

名单内容为什么
DEFAULT_OVERLAP_TOOL_NAMES :48read / bash / edit / write / grep / find / lsClaude Code 自己的实现更好,不重复造
DEFAULT_EXCLUDED_TOOL_NAMES :65echo在这里没有意义

剩下的(generate_imagecomputer_use、记忆、网页、delegate_task……)包成一个进程内 MCP server,名字叫 kun,模型看到的名字是 mcp__kun__<toolName>(bridgedToolModelNames,:191)。

三个值得记的决定:

  • delegate_task 走桥接而不是 SDK 的 agents 选项(:11-14),因为 kun 的委派更富:异步 detach、实时 profile 覆盖、每个子 agent 的独立禁用清单。
  • user_input 刻意不排除(:60-64):桥回 kun 自己的 GUI 输入面板;同时把 SDK 原生的 AskUserQuestion 加进 disallowedTools(sdk-options-builder.ts:44),因为它在这个宿主里没有 UI,模型问了也没人答
  • JSON Schema → Zod 只做尽力而为的顶层转换(jsonSchemaToZodShape,:143),复杂类型退化成 z.any()——真正的参数校验仍在 kun 自己的执行器里,这里只是把参数面告诉模型。

桥接后的 handler 捕获一切异常并转成 isError: true 的文本结果(buildBridgedToolSpecs,:114-134),不让一个工具异常炸掉 SDK 的整轮

8.5 注入之三:权限与身份

assembleSdkOptions(sdk-options-builder.ts:195)把 kun 的策略翻成 SDK 的选项:

kun 的东西落成 SDK 的什么
系统提示 + 线程 personasystemPrompt: {type:'preset', preset:'claude_code', append}(:122)
审批策略permissionMode 粗粒度(:87)+ canUseTool 每次调用细粒度(:147)
桥接工具名并进 allowedTools(:196-197)
订阅 token洗过的 env(:68)

buildCanUseTool(:147)有两处防御:decider 抛异常一律拒绝(fail-safe,:165-167);允许时必须回填 updatedInput——注释指出 SDK 的 TS 类型标它可选,但运行时 schema 会拒绝缺失值(:152-157)。这种"类型说可选、实际必填"的坑只有真踩过才知道。

buildScopedEnv(:68)是最容易被忽略却最要命的一段。SDK 认证有优先级:ANTHROPIC_API_KEY > ANTHROPIC_AUTH_TOKEN > apiKeyHelper > CLAUDE_CODE_OAUTH_TOKEN。如果宿主环境里恰好有前几个,这一轮会静悄悄走按量付费的 key,而用户以为自己在用订阅。所以七个可能覆盖的变量被逐个删掉(AUTH_OVERRIDE_ENV_KEYS,:52-60)再注入订阅 token。

还有一处克制:settingSources: [](:207)——不自动吸收宿主的 ~/.claude 配置,只加载 kun 给的。

模型 id 也要过滤:resolveSdkModel(:112)保证送进 SDK 的一定是 claude 开头的 id。一个从 DeepSeek 时期建的老线程带着 deepseek-v4-flash 切过来,会被强制换成运行时默认的 Claude 模型,否则 SDK 直接报 "model may not exist"。

计划模式反而刻意不映射到 SDK 的 'plan' 权限模式(kun/src/runtime/agent-sdk/agent-sdk-runtime-core.ts:262-267 的注释):那个模式会连桥接过来的 create_plan 一起禁掉。kun 改用"approvalPolicy 降为 never + 关掉 SDK 原生内置工具 + 桥接 Plan 过滤后的 kun 目录 + 伪造的原生调用一律拒绝"来达成同样效果(:259-269)。

8.6 反投影:让 GUI 分不出这一轮是谁跑的

SdkEventMapper(sdk-event-mapper.ts:113)是纯函数式的翻译器——只吃 SDK 消息、只吐 RuntimeEventDraft[],不做任何 IO,因此可以拿伪造消息完整单测。

SDK 消息翻成 kun 的什么实现
system/init记下 sessionId(仅诊断用):136-138
stream_eventcontent_block_deltaassistant_text_delta / assistant_reasoning_delta(增量)mapStreamEvent :153
assistant 完整消息item_created(权威全文)+ 每个 tool_use 的两条事件mapAssistant :170
user 里的 tool_resulttool_call_finishedtoolResultEvent :344
resultusage 事件 + 记录终局状态mapResult :212

"增量 + 一次性全量"这个双轨必须与原生 loop 严格一致,否则 GUI 会把内容渲染两遍。文件头注释(:15-21)把契约写死了:delta 事件的 item.text要被追加的片段,item_createditem.text用来整体替换的全文

另外两个小翻译:toolKindFor(:56)从工具名反推 kun 的 toolKind(bash→command_execution,edit/write→file_change),让 GUI 用对渲染组件;mapSdkUsage(:87)再次处理 Anthropic 的 token 语义——promptTokens = input + cache_read + cache_creation,和 §5.6 是同一条规则。

持久化上有一条节流:shouldPersist(kun/src/runtime/agent-sdk/agent-sdk-runtime-items.ts:5)只在 item 完成/失败或它是 tool_call 时才落库,流式 delta 只走事件不落盘。而落库时不再重复 record item_created(agent-sdk-runtime-core.ts:457-462),因为 applyItem 自己会发——否则 GUI 会收到两份。

8.7 边界与 MVP 痕迹

这条路是新支线,有几处诚实的未完成:

  • 审批在桥接工具侧仍是粗粒度:策略为 never 就全拒(kun/src/runtime/agent-sdk/agent-sdk-runtime-factory-tools.ts:248agent-sdk-runtime-factory-context.ts:279)。
  • SDK 的 sessionId 如今就是续接凭证:turn 结算时随检查点提交,下一轮命中就走 resume(见 §8.3);只有检查点缺失才回退到转录重放。
  • SDK 的类型被手工重新声明sdk-protocol.ts:1-20,而不是直接 import。理由写得很好:SDK 带着一个大的按平台分发的 Claude Code 二进制作为可选依赖,重声明让纯逻辑模块在没装它的 CI 里也能编译和跑单测;真正 import('@anthropic-ai/claude-agent-sdk') 的只有工厂里那一行动态字符串导入(kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.ts:13-18)。

9. 调试与回归:怎么看见/复现这一层

三件工具,粒度从细到粗:

工具看什么入口
LlmDebugRecorder最近 25 轮的原始请求 body + 原始输出kun/src/services/llm-debug-recorder.ts:41
runReplaySuite一组固定任务跑一遍,量 TTFT / 工具耗时 / usagekun/src/benchmark/replay-benchmark-runner.ts:322
transcript-diff两个线程的 usage 汇总并排比kun/scripts/transcript-diff.mjs:28

LlmDebugRecorder 的接入方式很干净:客户端只依赖一个窄接口 LlmDebugSink(kun/src/services/llm-debug-recorder-contracts.ts:141),stream() 在没配 sink 时零开销直通(kun/src/adapters/model/compat-model-client.ts:35 起),配了才开一轮记录并在 captureChunk(:61)里按 chunk 类型累积。它是纯内存环形缓冲(容量 25,llm-debug-recorder-contracts.ts:166),不落盘——完整 prompt 和历史不该被写进磁盘。

replay-benchmark 走的是真 HTTP + 真 SSE:建线程、发 turn、订阅事件流(createReplayHttpClient,kun/src/benchmark/replay-benchmark-quality.ts:40),然后 summarizeReplayEvents(kun/src/benchmark/replay-benchmark-runner.ts:541)从事件里算出首字延迟、工具调用次数与 p95 耗时、SSE 投递延迟。compareReplayReports(kun/src/benchmark/replay-benchmark-report.ts:51)拿它和基线比,这就是这一层的回归网。

transcript-diff.mjs 最轻:直接读两份 events.jsonl,只汇总 kind === 'usage' 的事件(:29-32),解析失败的行静默跳过(:43-45)——诊断工具就该宽容。它的典型用途是"改了缓存/协议之后,同一个任务的命中率和成本变了多少"。


10. GUI 侧:这些配置从哪来

用户在设置页看到的供应商,来自一张预设表 MODEL_PROVIDER_PRESETS(src/shared/model-provider-preset-catalog.ts:5)。每条预设带 baseUrl、endpointFormat、模型清单和每个模型的 profile。

其中 claude-subscription(src/shared/model-provider-preset-catalog-core.ts:89-114)是唯一带 kind: 'agent-sdk' 的一条:

  • baseUrl 被明确标注为仅供显示、不参与请求;
  • 认证来自宿主已有的 Claude Code 登录,或用户粘贴的 CLAUDE_CODE_OAUTH_TOKEN;
  • 上下文窗口是手填的,注释说明 SDK 不上报窗口大小(:103-106)。

kind 字段的定义在 src/shared/model-provider-preset-types.ts:216-223(四个值:agent-sdk / antigravity-cli / gemini-cli-api / cursor-sdk),一路原样透传:预设 → profile(modelProviderPresetProfile,model-provider-preset-operations-core.ts:59)→ 归一化(normalizeModelProviderProfile,app-settings-provider-profiles.ts:128)→ 运行时的 serve.providers(resolveKunRuntimeSettings,app-settings-provider-runtime.ts:197)→ kun/src/server/runtime-factory-model.ts:306 那个 (provider.kind ?? 'http') === 'agent-sdk' 判断 → 决定这个 provider 到底造 HTTP 客户端还是记进 agentSdkProviderIds

一个枚举值,从设置面板一直贯到"这一轮走哪条出口"。 这就是 §2 那张图上层分叉的来源。


11. 巧妙之处(可以带走的)

  1. 同一份历史修复,服务两个完全不同的目的。 repairModelHistoryItems(kun/src/domain/model-history-repair.ts:11)既是缓存一致性的保证,也是不吃 400 的防线——因为二者的约束本来就是同一条:tool_call 与 tool_result 必须严格配对。

  2. 空闲超时,而不是总超时。 readStreamChunk(kun/src/adapters/model/compat-model-support.ts:330)量的是"两块数据之间的间隔",思考久的模型不会被误杀,僵死连接跑不掉。

  3. 宁可交出坏参数,也不静默吞掉调用。 流尾的强制 flush(kun/src/adapters/model/compat-model-client-stream.ts:494-520)+ { __raw } 兜底(tool-argument-repair.ts:29),把"供应商标错 stop reason""参数被截断"这两类事故变成模型能自行纠正的工具错误。

  4. 探测式兼容优于配置开关。 stream_options 被拒就去掉重发一次(compat-model-support.ts:172 + compat-model-client.ts:269),用一次失败请求换掉一个用户根本答不上来的设置项。

  5. 协议可以按模型覆盖,不只按供应商。 endpointFormatForModel(compat-model-client-base.ts:93)承认了"同一个中转下不同模型说不同方言"这个现实。

  6. 在别人的引擎里跑,也要守住自己的 canonical state。 订阅引擎能 resume 就 resume、不能就把 kun 的历史重放成转录(agent-sdk-runtime-core.ts:253-317),换来跨重启、跨 provider 切换的连续性。

  7. 认证优先级是要主动清理的。 buildScopedEnv(sdk-options-builder.ts:68)删掉七个会顶掉订阅 token 的环境变量——不删的话,用户以为在用订阅,账单却记在 API key 上。

  8. 把外部 SDK 的类型抄一份。 sdk-protocol.ts:1-20 用重声明换来"没装 SDK 也能编译和单测",并把 SDK 变形的爆炸半径收在一个文件里。


12. 边界与局限

  • custom_endpoint 不认得的路径就直接失败。 URL 尾巴不是那四种之一,反推返回 null,请求根本不会发出(compat-model-client.ts:62-69)。这是刻意的:猜错协议比明确报错更难排查。
  • thinking 字段只在官方 DeepSeek host 自动开。 第三方兼容层要靠用户显式选档位(compat-request-reasoning.ts:314 requiresReasoningRoundTrip,issue #26)。
  • 非流式响应没有增量。materializeNonStreaming(kun/src/adapters/model/compat-model-client-stream.ts:653)一次性吐完,GUI 上没有打字机效果。
  • 历史窗口裁剪是保守的。 limitHistoryPreservingCompaction(kun/src/adapters/model/compat-message-projector.ts:624)裁剪时会回头找最近一条有效 compaction 项并保住它,但窗口本身仍是简单的"取最后 N 条"。
  • 订阅引擎的审批仍偏粗。 桥接工具侧只区分"策略 never"与"其他"(agent-sdk-runtime-factory-tools.ts:248)。
  • 订阅引擎只吃 Claude 模型。claude* 的 id 一律被换成运行时默认(resolveSdkModel,sdk-options-builder.ts:112)。
  • 本章不覆盖: 缓存策略与上下文压缩见 03;工具的执行与三道闸门见 04;turn 的生命周期见 02

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

主题文件路径符号名
模型端口(唯一契约)kun/src/ports/model-client.tsModelClientModelRequestModelStreamChunkModelToolSpec
三种线上形态kun/src/contracts/model-endpoint-format.tsresolveModelEndpointFormatmodelEndpointPathinferModelEndpointFormatFromUrl
主 HTTP 客户端kun/src/adapters/model/compat-model-client.ts · compat-model-client-base.tsCompatModelClientCompatModelClientConfig
消息拼装顺序kun/src/adapters/model/compat-message-projector.tsprojectCompatMessagesitemsToMessagestoolCallBlockToMessages
三种 bodykun/src/adapters/model/compat-request-codecs.tsCompatRequestCodecs.build
SSE 解析与累积kun/src/adapters/model/compat-model-client-stream.ts · chat-completions-stream-decoder.ts · responses-stream-decoder.ts · anthropic-messages-stream-decoder.tsstreamSse、三个协议 decoder
空闲超时看门狗kun/src/adapters/model/compat-model-support.tsreadStreamChunknormalizeStreamIdleTimeoutMsDEFAULT_STREAM_IDLE_TIMEOUT_MS
重试与降级kun/src/adapters/model/compat-retry-policy.ts · compat-model-support.tsshouldRetryWithoutStreamUsagesleepWithAbortnormalizeModelRequestRetryConfig
错误分类kun/src/adapters/model/compat-http-diagnostics.tsclassifyCompatHttpErrorredactUrlForLogsummarizeForLog
配对修复kun/src/adapters/model/compat-message-projector.tshealToolMessagePairsnormalizeThinkingAssistantMessages
思考档位翻译kun/src/adapters/model/compat-request-reasoning.tsapplyReasoningEffortapplyProfileReasoningEffortrequiresReasoningRoundTrip
用量与计费kun/src/adapters/model/compat-usage-normalizer.tsnormalizeCompatUsagemergeUsageSnapshots
工具参数修复kun/src/adapters/model/tool-argument-repair.tsrepairToolArgumentsextractBalanced
多 provider 路由kun/src/adapters/model/multi-provider-model-client.tsMultiProviderModelClientresolveconfigFor
DeepSeek 可达性探测kun/src/adapters/model/model-error-probe.tsisDeepSeekHostprobeDeepSeekReachable
代理 fetchkun/src/adapters/model/proxy-fetch.tscreateProxyFetch
价格表kun/src/adapters/model/{deepseek,minimax}-pricing.tsestimateDeepseekCostestimateMiniMaxCost
模型能力元数据kun/src/contracts/capabilities-core.tsModelCapabilityMetadataModelReasoningRequestProtocol
附件与降级kun/src/loop/turn-attachment-service.tsTurnAttachmentService.resolveTurnAttachmentsimageGenerationReferenceInstructions
附件存储与授权kun/src/attachments/attachment-store.tsFileAttachmentStore.resolveContentdetectImage
订阅引擎编排kun/src/runtime/agent-sdk/agent-sdk-runtime-core.tsAgentSdkRuntime.runTurnSdkRuntimeDeps
订阅引擎装配kun/src/runtime/agent-sdk/agent-sdk-runtime-factory.ts · agent-sdk-runtime-factory-turn.ts · agent-sdk-runtime-factory-context.ts · agent-sdk-runtime-factory-lifecycle.tscreateAgentSdkRuntimehandlesProviderloadAgentSdksessionCoordinator
无状态重放kun/src/runtime/agent-sdk/sdk-context-assembler.tsbuildHistoryTranscriptcomposeSdkPromptText
SDK 选项组装kun/src/runtime/agent-sdk/sdk-options-builder.tsassembleSdkOptionsbuildCanUseToolbuildScopedEnvresolveSdkModel
工具桥接kun/src/runtime/agent-sdk/sdk-tool-bridge.tsselectBridgeableToolstoSdkMcpServerbridgedToolModelNames
事件反投影kun/src/runtime/agent-sdk/sdk-event-mapper.tsSdkEventMappermapSdkUsagetoolKindFor
SDK 类型影子层kun/src/runtime/agent-sdk/sdk-protocol.tsSdkApiSdkQueryOptionsSdkMessage
出口分叉点kun/src/loop/agent-loop-turn-lifecycle.tsrunTurn 顶部的 sdkRuntime.handlesProvider
全部装配kun/src/server/runtime-factory-model.ts · runtime-composition-model.ts · runtime-composition-agent.tsdefaultModelClientagentSdkProviderIdsForOptionssdkRuntime
调试录制kun/src/services/llm-debug-recorder.tsLlmDebugRecorderLlmDebugSink
回归基准kun/src/benchmark/replay-benchmark-runner.ts · replay-benchmark-report.ts · replay-benchmark-quality.tsrunReplaySuitesummarizeReplayEventscompareReplayReports
usage 并排比kun/scripts/transcript-diff.mjssummarizeEvents
GUI 供应商预设src/shared/model-provider-preset-catalog.ts · model-provider-preset-catalog-core.ts · model-provider-preset-operations-core.tsMODEL_PROVIDER_PRESETSmodelProviderPresetProfile
GUI 设置解析src/shared/app-settings-provider-runtime.ts · app-settings-provider-profiles.tsresolveKunRuntimeSettingsnormalizeModelProviderProfile