Cherry Studio 上下文构建/压缩运行时问题排查实录:从tool_invoke400 到实体级 Tool Output Codec 根本修复
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
本文基于 Cherry Studio 仓库
v2-refactor-temp/docs/ai/2026-08-02-context-build-runtime-test-findings.md展开,并结合当前仓库源码(src/main/ai/tools、src/main/ai/contextBuild、src/main/ai/streamManager/persistence、packages/aiCore/src/core/context、src/shared/ai/transport等)进行源码级印证与扩充。
导读
Cherry Studio 在长程工具任务(20 步read_file循环、多轮联网搜索)与压缩(compaction)机制共同作用下,暴露出 7 类运行时问题:从 Anthropic 端点因tool_invoke的inputExamples与序列化 schema 不匹配而整请求 400,到压缩折叠附件后read_file失联、read 类工具回声全量占库、in-loop 压缩成本线性放大,再到 trace 持久化崩溃与 MCP 解析非确定性。本文完整复述这些问题的现象、根因、修复方案与验证结果,并深入剖析最终落地的「实体级裁剪(Tool Output Codec)」根本修复——把工具输出从"不透明字节流"升级为"骨架 + 内容块"的结构化裁剪模型。读完你将对 Cherry Studio 上下文构建的 in-flight 截断、persist 落库、压缩召回与前缀缓存四条链路有完整认识,并掌握结构感知截断的设计原则与实现细节。
测试环境与方法
测试采用克隆档案CherryStudioCtxTest:将上下文裁剪阈值从默认 5000 调至 100000、context_window缩至 16000,配合 cherry-electron-dev 实例,由真实模型(aihubmix::claude-sonnet-4-6 / gemini::gemini-2.5-flash)驱动长程工具任务。测试范围是feat/context-build-truncation分支 @7f0a0fbd34,其中 #1、#5、#6 为 main 既有问题,与本分支无关。
关于压缩的三种机制需要先厘清边界,测试结论才有意义:
- in-loop 压缩:主循环某步超限时折叠旧轮次,见
packages/aiCore/src/core/context/inLoopCompaction.ts; - durable 压缩:turn 开始前一次性折叠 keep 边界之前的轮次,见
src/main/ai/contextBuild/PersistentChatContextProvider的resolveCompactedHistory; - persist 裁剪(v1):
message.data落库前对超大工具输出的裁剪,见src/main/ai/streamManager/persistence/trimToolOutputs.ts。
修复状态总览(2026-08-03,全部在本分支落地)
| 问题 | 状态 | 提交 |
|---|---|---|
| #1 tool_invoke schema 400 | ✅ 已修复(两层覆盖:手写jsonSchema()绕过 provider-utils +sanitizeSchema保留显式additionalProperties:true) | 2ec4f0c07f/@ai-sdk__anthropic.patch |
| #2 压缩折叠附件后 read_file 失联 | ✅ 已修复(权威附件清单随请求下发 + 摘要行附附件清单提示) | 81a7e1ee37 |
| #3 read 类工具回声全量占库 | ✅ 已修复(read_file / fs_read persist 侧 text-field codec) | 793b77885f |
| #4 in-loop 压缩无记忆化 | ✅ 已修复(prepareStep 闭包内增量折叠缓存,零重复摘要化) | 8bfe78af9a |
| #5 AiTurnTrace 持久化崩溃 | ✅ 已修复(non-recording span 不再进 convert/sink;spanConvert 加防御) | 479d4a370a |
| #6 MCP 解析非确定性 | ✅ 已修复(解析前 warm 目录缓存 + 三层 mcpMode 缺省统一'manual') | 9c349245e8 |
| #7 web 工具截断(根本修复) | ✅ 已落地 P1-P3(实体级 codec;P4 truncatable 布尔退役为后续项) | f406425279/c10902e244/793b77885f |
#1tool_invoke的 inputExamples 与序列化 schema 不匹配 → Anthropic 端点整请求 400
严重度:高(main 既有 bug,建议单独开 issue)
现象:tool defer 触发(auto 池 > 窗口 10% 等三条件)后,meta 工具进入请求,Anthropic 系端点(实测 aihubmix)返回tools.N.custom: Example at index 0 is invalid: False schema does not allow "cherry studio latest release". Each example must match the tool's input_schema.整个请求失败。同一请求去掉 MCP(defer 不触发)即成功;换 Gemini 端点(不校验示例)defer 全链路正常(tool_search/tool_invoke 均验证通过)。
根因(第一层):src/main/ai/tools/adapters/aiSdk/meta/toolInvoke.ts的inputExamples: [{ input: { name: 'web_search', params: { query: 'cherry studio latest release' } } }]与params: z.record(z.string(), z.unknown()).optional()经 zod→JSON Schema 序列化后的结果不匹配。真正原因不在 zod 序列化本身——zod v4 产出的additionalProperties: {}是对的,是@ai-sdk/provider-utils的addAdditionalPropertiesToJsonSchema对每个 object 节点无条件覆盖additionalProperties: false,把params变成不接受任何属性的死对象(example 校验 400,模型正常传参同样违反)。
修复(第一层,2ec4f0c07f):toolInvoke.ts改用手写jsonSchema()——asSchema对已包装 schema 原样放行,跳过 provider-utils 的覆盖;params显式additionalProperties: true,运行时校验保留原 zodsafeParse;inputExamples保留(修后合法)。当前源码中可看到这一实现:
const toolInvokeInputSchema = jsonSchema<{ name: string; params?: Record<string, unknown> }>( { type: 'object', properties: { name: { type: 'string', description: 'Tool name as returned by tool_search' }, params: { type: 'object', additionalProperties: true, description: 'Tool input arguments' } }, required: ['name'], additionalProperties: false }, { validate: (value) => { const result = toolInvokeInputZod.safeParse(value) return result.success ? { success: true, value: result.data } : { success: false, error: result.error } } } )根因精化(第二层,patches/@ai-sdk__anthropic.patch):第一层修复后 aihubmix 端点仍复现同一 400。追因发现@ai-sdk/anthropic的sanitizeSchema(由本仓 patch 把sanitizeJsonSchema接入prepareTools后,对每个工具input_schema生效)在发送前又一次对每个 object 节点无条件result.additionalProperties = false,把 asSchema 已放行的params: additionalProperties:true重新压回false。asSchema 层(provider-utils)不再覆盖,但 provider 自己的 sanitizer 覆盖——两层是独立的两处 clobber。修法:改sanitizeSchema为result.additionalProperties = schema.additionalProperties === true ? true : false(仅保留显式true,其余仍默认闭合,普通工具零影响)。边界测试aihubmix.anthropicTools.test.ts新增用例钉住 wire schema:input_schema.properties.params.additionalProperties === true且外层对象仍false——直接跑真实prepareTools→sanitizeJsonSchema序列化路径,无需 Electron。
影响面:Anthropic 家族端点 + auto 池过线(默认 200k 窗口需 ≈20k tokens 的 MCP 工具描述,约几十个工具;小窗口模型更容易)。多 MCP 重度用户会真实命中,且表现为"开了很多 MCP 后 Claude 突然全部请求报错"。补充测试中 #1 再次命中且路径更真实:重启后新话题自动继承助手绑定的 MCP(即 #6 行为),16k 窗口下 auto 池过线 → defer 触发 → aihubmix(Anthropic API)连续 6 个请求 400,用户无任何显式操作即全灭;取消话题 MCP 选择后恢复。
修复方向(任一):对齐 example 与序列化后的 schema;去掉inputExamples;anthropic provider 侧在发送前丢弃与 schema 不符的 examples。注意toolSearch.ts/toolInspect.ts的 examples 需一并核查——核查结果:examples 全为已声明属性,不中招。
#2 durable 压缩折叠附件消息后,read_file 失联、细节不可恢复
严重度:中(本分支相关的交互缺口)
现象:16k 窗口下第一轮(20 页 read_file)结束后,第二轮 turn 开始触发 durable 压缩,带附件的 user 消息被折进compaction_summary。此后:
- served 消息里没有 file part →
collectFileAttachments为空 →hasFileAttachments=false→read_file 不再注册,模型想重读附件也做不到(实测模型自报工具列表无 read_file); - 只能凭摘要回答细节,实测答错(问 LOG-0001 的 temperature,答 119,实际 21.1)。
对比:工具输出有<persisted-output>marker + fs_read 幸存通道(信封渲染 + 历史 allow-list 注入,跨压缩仍可读回);普通附件没有对应通道。
修复(81a7e1ee37):两条修复方向都做了——resolveCompactedHistory从 RAW 路径行收集权威fileAttachments清单,经AiStreamRequest.fileAttachments(main 内部字段,不过 IPC)下发,buildAgentParams优先取它(read_file 注册 + allow-list 与 served parts 解耦);同时 durable 摘要行附加附件清单提示([Files attached in this conversation remain readable in full via the read_file tool: …],纯存储字段渲染,字节稳定),恢复模型的调用信号。
位置:PersistentChatContextProvider.resolveCompactedHistory(折叠)×buildAgentParams.collectFileAttachments(从 request.messages 收集)。
#3 read 类工具回声全量占库(v1 裁剪范围边界,两轮测试均出现)
严重度:中(已知范围决策,量化后建议提优先级)
现象:
- 测试 1:模型跟随 marker 用 fs_read 读回 3 份全文,fs_read 的结构化输出(
{kind,text,...},truncatable:false + 非 string/mcp-content 形状)不参与 persist 裁剪 → 95KB 消息中 ~66KB 是 fs_read 回声; - 测试 2:20 个 read_file 结构化输出 169KB 全量入
message.data。
本质:v1 裁剪范围只收 string 与全文本 MCP 信封(见src/main/ai/contextBuild/toolOutputStore.ts的extractPersistableText:仅纯字符串与 content 全为 text block 的 MCP 信封;结构化 JSON、多模态 MCP 内容一律返回 null 保持全量);read 工具(fs_read/read_file)的结构化输出即使巨大也全量入库。模型越勤快读回,DB 省得越少。
修复(793b77885f,经由 #7 codec P2/P3):
- read_file 挂
makeTextFieldCodec({textKey:'text'})(persist 专属——其 toModelOutput 是 text,in-flight 实体路径只认 json,永不触发),169KB 级页回声落库时text进 blob、分页字段留骨架; - fs_read 保留 in-flight
truncatable:false(防循环)+ 同款 codec 走 persist lane——默认配置下不触发(输出 cap == persist 阈值,裁剪门限为严格>),阈值调低时才裁,重复读同页经 contentHash 收敛到同一 echo blob。
注意一个实现细节:echo 带 cat -n 行号格式(见FsReadTool.ts的formatLines:6-pad 行号 + tab),不会命中源 blob 的 contentHash——设计稿中"零额外存储"不成立,重复读同页仍收敛到同一 echo blob(而非源 blob)。
#4 in-loop 压缩无记忆化的成本放大(代码已注明 accepted cost,此处量化实测)
严重度:低-中(优化项)
实测:16k 窗口、20 步 read_file 循环中,主循环步输入被正确压至 9-11k tokens,但每个超限步全量重折叠旧轮次:摘要化调用输入 44k→50k→60k→66k 递增。整轮 20 请求共 446k input tokens($0.83,prompt cache 吸收 cacheRead 252k)。
修复(8bfe78af9a):prepareStep 闭包内缓存{consumedCount, compactedPrefix},超限步先构造[...compactedPrefix, ...messages.slice(consumedCount)]——低于触发线直接复用(零 LLM 调用),仍超限才折叠增量并更新缓存(摘要折摘要,与 durable 语义一致)。摘要化调用输入从 O(全历史) 降到 O(增量),44k→66k 的递增消失。此前inLoopCompaction.ts头注释已标 "no memoization in v1"。
#5 turn 以 ToolLoopTerminalError 终止时 AiTurnTrace 持久化崩溃
严重度:低(main 既有,观测性缺口)
现象:WARN [AiTurnTrace] Failed to persist root span ai.turn TypeError: Cannot read properties of undefined (reading '0') at AiStreamManager.onExecutionError——恰好在最需要 trace 的异常终止路径上丢了 trace。
根因修正:原记录"异常终止路径"是采样偏差——真实根因是developer mode 关闭时没有 TracerProvider,startSpan返回 NonRecordingSpan(无startTime),end 补丁无条件convertSpanToSpanEntity→span.startTime[0]抛 TypeError。每次 turn 结束都崩,与 outcome 无关,只是 WARN 淹没在正常日志里、异常终止时才被注意到。
修复(479d4a370a,双保险):AiTurnTraceend 补丁对无startTime的 span 直接 no-op 返回(不 convert 不写 sink);spanConvert.ts补 startTime 防御(与既有 endTime 守卫同风格)。新增无 provider 用例:handle.end()不 throw、sink 不被调。
#6 mcpToolIds 空数组不回落到助手绑定(行为待确认,可能按设计)
严重度:待确认
前提修正:原记录的"渲染端传空数组"前提不成立——聊天 IPC schema 根本没有mcpToolIds字段,composer 的 MCP 选择器写的是助手级mcpServerIds,聊天请求恒走resolveAssistantMcpToolIds回落。
非确定性另有两源:
McpCatalogService.listToolscache-only——冷缓存返回[]只触发异步预热(空结果还有 5 分钟退避),重启后首个话题 vs 后续话题因此不一致;- 三层 mcpMode 缺省不一致(main
'manual'/'disabled'、shared DEFAULT'auto'、renderer'disabled')。
修复(9c349245e8):解析前对目标服务器await warmToolsCache(server.id)(冷缓存不再静默空集);三层缺省统一为 sharedDEFAULT_MCP_MODE = 'manual'。新增 resolveAssistantMcpTools 确定性测试。
附:测试中确认不是 bug 的现象
- 第一轮长循环以
ToolLoopTerminalError(20 步工具上限)终止 —— 步数护栏,按设计。 - gemma 免费层配额报错 —— 外部配额限制。
- 压缩后细节回答不精确本身是摘要化的固有代价;#2 记录的是"想重读也读不到"的通道缺失。
补充测试:网络搜索 × 压缩召回率 × 前缀缓存命中(AI_SDK_DEVTOOLS=1)
场景:16k 窗口(aihubmix::claude-sonnet-4-6,前两轮 gemini-2.5-flash),4 轮联网搜索(巴黎奥运金牌/C919 航程/SQLite 版本/珠峰高程)+ 4 轮禁搜索召回测验;devtools 捕获全部 24 个请求载荷于
.devtools/generations.json。
结论 1:web 搜索结果没有持久化/截断通道(设计如此,记录为潜在跟进)
web_search/web_fetch均truncatable: false(引用工具,citation 抽取需要原文)——搜索结果双份全量:出站 prompt 内联 +message.data全量,唯一的瘦身通道是压缩层(折叠旧轮)。另有独立的chat.web_search.compression(method/cutoff_limit,本档案为 none)在结果进入上下文前做源级压缩,与 context-build 无关。若给 citable 工具开 persist 通道,需先解决 citation 与 marker 的共存。
✅ 2026-08-03 起已过时:#7 codec P1-P3 落地后,web_search/web_fetch/kb_search 均走实体级截断+持久化(citation 骨架落库,渲染端从 skeleton 解析),源级压缩默认也翻为 cutoff(新装)。
结论 2:durable 压缩对搜索轮生效,prompt -68%,写一次服务多次
搜索 4 轮后估算过线,durable 压缩在 turn 开始触发一次(边界行写入 2,391 字符摘要;耐人寻味:边界行是一条 error 消息,收尾时仍被正确选中)。devtools 实测出站 prompt 从 28KB 降到 9KB(-68%);之后 8 个请求全部复用同一摘要行,无重复摘要化调用(对照 in-loop 的每步重折叠,durable 是 write-once-serve-many)。
结论 3:召回测验 4/4 全对(含被折叠轮次)
- 被折叠的 R1(巴黎奥运 40 金):精确召回,模型自述"根据对话摘要中保留的信息" ✅
- 被折叠的 R2(C919):精确复述了失败情形(页面内容为空、无 web_fetch、未给最终数字),摘要甚至保留了"备用知识 5555km 但当时未作为答案"的区分 ✅
- 未折叠的 R3/R4(SQLite 3.51.0 / 2025-11-04、珠峰 8848.86 米):逐字召回 ✅
- 摘要质量注记:摘要为结构化英文 digest(✅ Completed / ❌ Failed/Incomplete / Context to Preserve),对"数字型事实"的保真明显好于 read_file 测试中对 2000 行日志明细的保真(#2 的 LOG-0001 答错)——摘要保真度与信息密度强相关,事实型 QA 场景召回率高,海量明细场景仍需读回通道。
结论 4:前缀缓存命中率 ≈99.9%(字节稳定的直接证据)
压缩启用后连续 9 个成功请求的 Anthropic usage:每步noCache仅1-3 tokens,cacheRead3,087→4,604 递增,cacheWrite只覆盖新增后缀——摘要行渲染 + 全量 web 结果在多轮间字节完全稳定,provider 前缀缓存几乎满命中。Gemini 两轮的隐式缓存命中率约 60-80%。
过程中复现的既有问题
- #1 再次命中且路径更真实:重启后新话题自动继承助手绑定的 MCP(即 #6 行为),16k 窗口下 auto 池过线 → defer 触发 → aihubmix(Anthropic API)连续 6 个请求 400,用户无任何显式操作即全灭;取消话题 MCP 选择后恢复。
- anthropic 直连与 gemini 免费层的配额/余额错误为外部因素(gemini free tier 5 req/day)。
#7 web_fetch/web_search 是否应参与截断——分析与分层建议
性质:改进方案(承接补充测试结论 1;web_fetch 建议尽快做)
豁免现在真正保护的两层
- In-flight 引用诚实性:两工具输出为
[{id:'<prefix>-<n>', title, url, content}]JSON 数组(webLookup.tsmapResponse),模型靠"看见的条目"回写[cite:id]。截断器对 json 是 stringify 后掐 head/tail——cite id 与 content 的映射会被从中间切碎,模型对未见条目要么弃引(信息损失)要么幻引(更糟)。 - 落库后的 citation 渲染:
src/renderer/utils/message/citations.ts的 citation registry就地从 message.data 的工具输出 parts 解析("no persisted reference metadata")。输出被信封替换后,历史消息的角标/来源卡/导出/复制全部失解。
现状的真实风险(实测支撑)
- 压缩层从不折当前 turn(durable 只折 keep 边界前,in-loop 至少保一 turn)——当前轮一发超大 fetch 结果没有任何防线。
web_fetch的 readable content 无工具级上限(长文档页 50-200k chars),是目前唯一完全裸奔的上下文洪水源;search(max_results=5×snippet)实测单轮仅 3-6k chars。 - 双份全量落库,重复搜索不去重。
- 源级压缩
chat.web_search.compression(postProcessing.ts,cutoff/rag)存在但默认 none。
分层建议
- web_fetch:应改为可截断,优先做。引用身份是 URL 且在input里(result 骨架也有),截 content 不损失引用身份;内容为单篇正文,head/tail + marker + fs_read 读回与 filesystem read 语义同构;又是最大单发洪水源。in-flight 翻 flag 即止血;persist 侧待
shape:'json'(#3)配 citation-aware 信封(保留{id,url,title}骨架)。 - web_search:不建议裸翻 flag。默认 100k 阈值下几乎永不触发(no-op),触发时伤的恰是引用结构。正确杠杆是给源级 cutoff 一个温和默认值(逐条裁 content、天然保留每条 id/url/title),而非动 truncatable。
- 终局抽象:结构感知截断。布尔 flag 表达不了 citable 工具的需求——按 result 条目为单位裁 content、永不裁引用骨架。可在 truncator
perTool上扩展 per-tool 自定义 reducer,让 web_search/web_fetch/kb_search 都能安全参与截断+持久化。
行动排序:① web_fetch 翻 flag(一行,立即止血)→ ② search 源级 cutoff 默认值 → ③shape:'json'+ citation-aware 信封(与 #3 合并)→ ④ per-tool reducer。
量级 sanity check:200k 正常窗口下 search 需 ~100+ 轮才顶满(压缩层足够);fetch 一发长页即可 50k+——优先级由此而来。
✅ 已落地(升级为下节的 codec 方案,未走"裸翻 flag"路线):web_fetch 直接上实体 codec(P1,f406425279)而非布尔翻转;web_search/kb_search 同 codec(P3,793b77885f,默认阈值下近 no-op、纯保险网);源级 cutoff 翻 schema 默认'none'→'cutoff'(经 classification 重新生成,仅新装生效,存量迁移写入的显式值不动)。citation 共存由 P2 的 skeleton 落库 + 渲染端 skeleton 解析解决。
根本修复设计:内容/结构分离的「实体级裁剪」(Tool Output Codec)
根因一句话:现在的截断把工具输出当不透明字节流(extractText → head/tail),而工具输出实际是有结构的——身份字段(id/url/title)+ 大体积内容字段。字节级掐断毁结构,整工具豁免弃防线;布尔
truncatable表达不了"裁内容、保骨架"。根本修复 = 把裁剪单位从字节流改成实体的内容字段。
核心抽象:每个工具注册一个输出编解码器(codec)
设计稿接口定义在src/main/ai/tools/adapters/aiSdk/types.ts(ToolEntry 声明):
interface ToolOutputCodec<TOutput> { /** 拆分:骨架(身份/引用字段,永不裁) + 可裁文本块(每实体一块) */ deflate(output: TOutput): { skeleton: unknown; blobs: Array<{ key: string; text: string }> } | null /** 重建:骨架 + 全文块 → 原始输出(渲染端展开 / ai.tool.get_result 用) */ inflate(skeleton: unknown, blobs: Record<string, string>): TOutput } // 未注册 codec = 现状 'opaque'(字符串/全文本 MCP 走既有 head/tail); // 'exempt' 仅留给真正永不裁的场景(如 fs_read 的 in-flight 防循环豁免)。预置 codec:
- web_search / web_fetch / kb_search:
entities形——骨架 =[{id,url,title}],blobs = 各条content。超阈值只裁单条 content 为 head/tail + marker,引用骨架在 prompt 与 DB 双侧永不有损; - fs_read / read_file(#3 顺带解决):
json-text-field形——骨架 ={kind,startLine,...},blob =text字段。fs_read 保留 in-flight 豁免(防循环),但persist 侧裁回声;整页读回的 blob 经 contentHash 去重直接命中它刚读的那个 entry,零额外存储(见下节偏差记录,实际因行号格式不命中源 blob)。
当前仓库中的实际实现(src/main/ai/tools/outputCodec.ts)提供了两个 codec 工厂:
makeEntitiesCodec({ contentKey }):面向[{…identity, [contentKey]: string}]形态(web_search / web_fetch / kb_search 结果),每个实体的 content 是 key 为"/<index>/<contentKey>"的一个 blob,其余全部留在骨架;makeTextFieldCodec({ textKey }):面向单记录 + 单个大文本字段(fs_read / read_file 回声),一个 blob key 为"/<textKey>"。
两者共享assemble语义:浅克隆并保持键插入顺序——组装后的值在两条 lane 上都会被 stringify 进 prompt,必须字节稳定以维持前缀缓存命中。snippet使用CITATION_SNIPPET_MAX_CHARS截断内容用于骨架内联摘录。注意实现细节:deflate对非目标形状(错误对象、steer 字符串、marker)返回null,走不透明回退路径。
统一管线(两条 lane 共享一个裁剪原语)
设计稿管线如下:
trimToolOutput(toolName, output, budget) // 唯一入口,查 registry codec ├─ in-flight(truncator):裁后的实体渲染 per-entity marker → 出站 prompt └─ persist(trimToolOutputs):信封扩展为多 blob 形态 $persistedToolOutput: { shape: 'text' | 'mcp-content' | 'entities' | 'json-text-field', skeleton, // 引用骨架原样落库 → citation registry 照常就地解析 blobRefs: [{ key, fileEntryId, vfsFilename, head, tail, totalChars, totalLines }] }in-flight 侧(packages/aiCore/src/core/context/truncator.ts):TruncateOptions.perTool新增codec?: EntityToolOutputCodec字段,工具策略对象化后,"JSON 输出 + codec" 走truncateEntities:先codec.deflate(output.value)得到骨架与 blobs,逐 blob 判断text.length > threshold && headChars + tailChars < text.length决定是否裁剪(含"永不重复裁剪已持久化 marker"守卫);超预算且有 storage 时经Offloader.offloadAsync持久化换取 URI 标注的 head/tail + marker,无 storage 时走内联--- truncated (N lines, M chars total) ---形式;最后codec.assemble(deflated.skeleton, texts)重组。deflate → null(错误对象、steer 注记等)回退不透明路径。extractText只负责不透明路径的文本抽取,实体路径完全绕开它。
persist 侧(src/main/ai/streamManager/persistence/trimToolOutputs.ts):trimOversizedToolOutputs与 in-flight 中间件同层取阈值(globals + assistant override 快照,两 lane 用同一有效阈值);builtin registry 有codec则走trimViaCodec(超限 blob 各自落一个 FileManager entry,骨架内联 snippet 后生成{ shape:'entities', skeleton, blobRefs }信封),无 codec 且truncatable:false则跳过,否则走 v1 全文本 lane(trimWholeText)。storage 失败 per-part 非致命——宁胖勿丢,全量保留在 message.data。
信封类型(src/shared/ai/transport/persistedToolOutput.ts):v1 单 blob 信封PersistedToolOutputSingleRef(shape'text' | 'mcp-content',字段集冻结),v2 多 blob 信封PersistedToolOutputEntitiesRef(shape:'entities'+skeleton+blobRefs)。blobRefsOf把两种信封统一成 blob 视图(单 blob → key"");envelopeDisplayExcerpt为传输/渲染端提供首个 blob head + 末个 blob tail + 合计统计的形状无关摘录。$persistedToolOutput哨兵键是唯一判别依据。文件头注释明确了与deferredToolResult.ts(仅传输层裁剪、DB 保留全文、渲染端回取)的边界:本模块 DB 本身只存摘录。
内容寻址与生命周期(src/main/ai/contextBuild/toolOutputStore.ts):全文 blob 存放在 FileManager internal entry(cleanupPolicy: 'delete_when_unreferenced'),由chat_message_file_ref(role'tool_output')把生命周期绑定到消息;contentHash去重保证同一输出跨 turn / regenerate 只存一份;computeVfsFilename用 sha256 前 16 位生成vfs_<sha256[:16]>.txt,与 in-flight offloader 的_generateFilename字节一致,保证 marker URI 两 lane 相同;isToolOutputBlobEntry提供消费侧的严格门禁(origin internal + 自动清理 + txt 扩展名)。
每个 blobRef 一条tool_outputfile ref(一消息多 ref 已支持);fs_read 的 per-request allow-list 收集全部 blobRefs 路径;ai.tool.get_result/ 渲染端展开走inflate。
五条不变量(「根本」的定义)
- 引用骨架永不有损——prompt 侧模型看全每条 id/url/title(内容为摘录),DB 侧 citation registry 解析不变;
- 单一裁剪原语——形状判断只写在 codec 里一处,in-flight 与 persist 永不漂移(今天的
extractPersistableText/truncatorextractText双实现合并); - 确定性渲染——marker/骨架为存储字段的纯函数,字节稳定,前缀缓存维持 ≈100% 命中(已有契约测试模式直接沿用);
- 全文永可读回——每个被裁 blob 都有 marker + fs_read 通道 + UI 展开;
- 失败回退全量——codec 抛错则该输出原样落库(宁胖勿丢,沿用现有 per-part try/catch)。
落地阶段
| 阶段 | 内容 | 消化的问题 | 状态 |
|---|---|---|---|
| P1 | codec 接口 + web_fetch entities codec(仅 in-flight) | #7 最大洪水源止血 | ✅f406425279 |
| P2 | 多 blob 信封 + skeleton 落库 + inflate 读回/展开 | #7 persist 侧、citation 共存 | ✅c10902e244(顺带修了 topics GET 不投影导致的冷重载裸信封渲染 bug) |
| P3 | web_search/kb_search codec + 源级 cutoff 默认值;fs_read/read_file 回声 codec | #3 全量消化 | ✅793b77885f |
| P4 | 删除布尔truncatable(迁移为 codec/exempt 声明),truncator 的extractText与extractPersistableText合并进 codec | 双实现漂移风险清零 | ⬜ 后续项(本轮保留 truncatable 作 in-flight 豁免语义:codec 与 flag 并存时,in-flight preserve、persist 走 codec) |
落地与设计的偏差记录
实现用deflate/assemble(in-flight 重组)+ 通用spliceTextAtKey/inflateEntities(persist 渲染/读回,靠 blob key 的 JSON-pointer-lite 自描述,不依赖 codec 存在)替代了设计稿的deflate/inflate对;信封 shape 只增'entities'一种(json-text-field由单 blob 的 entities 信封覆盖,key: '/text');fs_read 回声 blob 因 cat -n 行号格式不会命中源 blob 的 contentHash(设计稿的"零额外存储"不成立,重复读同页仍收敛到同一 echo blob)。跨 lane 字节契约由测试钉死:persist 渲染的 entities 输出与 in-flight truncator 输出JSON.stringify逐字节相等。
触点清单(供深入阅读)
src/main/ai/tools/adapters/aiSdk/types.ts:ToolEntry 声明(codec 字段与 truncatable 并存);packages/aiCore/src/core/context/truncator.ts:in-flight 截断器,perTool.codec实体裁剪路径;src/shared/ai/transport/persistedToolOutput.ts:$persistedToolOutput信封(v1 单 blob + v2 entities),守卫向后兼容;src/main/ai/streamManager/persistence/trimToolOutputs.ts:persist 侧统一原语,codec → entities 信封、无 codec → v1 全文本 lane;src/main/ai/contextBuild/toolOutputStore.ts:全文 blob 的内容寻址存储、去重与生命周期;src/main/ai/tools/outputCodec.ts:makeEntitiesCodec/makeTextFieldCodec工厂实现;src/main/ai/tools/webLookup.ts/FsReadTool.ts/src/main/ai/tools/adapters/aiSdk/builtin/ReadFileTool.ts:codec 定义与 in-flight 豁免语义;src/renderer/utils/message/citations.ts:skeleton 解析(预期零改动)。
结语:四层防御协同后的稳定形态
经过上述修复,Cherry Studio 上下文构建形成了四层协作的稳定形态:in-flight truncator(实体级 codec 裁剪 + marker)→persist trimmer(同一 codec 生成entities信封,全文进 FileManager blob)→fs_read/read_file 读回通道(marker + allow-list,跨压缩仍可读回全文)→compaction(durable 写一次服务多次、in-loop 增量折叠零重复摘要化)。实测证据表明:压缩启用后出站 prompt 可降 68%、前缀缓存命中率 ≈99.9%、事实型召回 4/4;而「骨架永不有损、单一裁剪原语、确定性渲染、全文永可读回、失败回退全量」五条不变量则从设计层面杜绝了字节级截断对引用结构与可读性的破坏。若需深入某条链路,可沿上文触点清单直接阅读对应源码与测试。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考