v2-refactor-temp/docs/ai/2026-08-02-context-build-runtime-test-findings.md
更新日期:2026-08-03(修复状态标注) 测试方式:克隆档案
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 既有问题,与本分支无关)。 已验证正常的部分见tool-result-db-trim.md文末「实施结论」;此文只记问题。
| 问题 | 状态 | 提交 |
|---|---|---|
| #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 |
严重度:高(main 既有 bug,建议单独开 issue)
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 序列化后的结果不匹配(子 schema 落成 false),Anthropic API 新增的示例校验按 schema 拒绝该 example。inputExamples;anthropic provider 侧在发送前丢弃与 schema 不符的 examples。注意 toolSearch.ts/toolInspect.ts 的 examples 需一并核查。2ec4f0c07f,第一层)+ 根因精化:实际根因不在 zod 序列化本身——zod v4 产出的 additionalProperties: {} 是对的,是 @ai-sdk/provider-utils 的 addAdditionalPropertiesToJsonSchema 对每个 object 节点无条件覆盖 additionalProperties: false,把 params 变成不接受任何属性的死对象(example 校验 400,模型正常传参同样违反)。修法:toolInvoke.ts 改用手写 jsonSchema()(asSchema 对已包装 schema 原样放行,跳过覆盖),params 显式 additionalProperties: true,运行时校验保留原 zod safeParse;inputExamples 保留(修后合法)。toolSearch/toolInspect 核查过:examples 全为已声明属性,不中招。patches/@ai-sdk__anthropic.patch):第一层修复后 aihubmix 端点仍复现同一 400。追因:@ai-sdk/anthropic 的 sanitizeSchema(由本仓 @ai-sdk__anthropic.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。严重度:中(本分支相关的交互缺口)
compaction_summary。此后:
collectFileAttachments 为空 → hasFileAttachments=false → read_file 不再注册,模型想重读附件也做不到(实测模型自报工具列表无 read_file);<persisted-output> marker + fs_read 幸存通道(信封渲染 + 历史 allow-list 注入,跨压缩仍可读回);普通附件没有对应通道。PersistentChatContextProvider.resolveCompactedHistory(折叠)× buildAgentParams.collectFileAttachments(从 request.messages 收集)。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: …],纯存储字段渲染,字节稳定),恢复模型的调用信号。严重度:中(已知范围决策,量化后建议提优先级)
{kind,text,...},truncatable:false + 非 string/mcp-content 形状)不参与 persist 裁剪 → 95KB 消息中 ~66KB 是 fs_read 回声;message.data。shape:'json'(结构化输出序列化后裁剪+重建);或单独让 read 类工具输出参与 persist 裁剪——persist 层裁剪不会引发 in-flight 循环(fs_read 的 in-flight 豁免可保留)。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 行号格式,不会命中源 blob)。严重度:低-中(优化项)
inLoopCompaction.ts 头注释已标 "no memoization in v1"。8bfe78af9a):prepareStep 闭包内缓存 {consumedCount, compactedPrefix},超限步先构造 [...compactedPrefix, ...messages.slice(consumedCount)]——低于触发线直接复用(零 LLM 调用),仍超限才折叠增量并更新缓存(摘要折摘要,与 durable 语义一致)。摘要化调用输入从 O(全历史) 降到 O(增量),44k→66k 的递增消失。严重度:低(main 既有,观测性缺口)
WARN [AiTurnTrace] Failed to persist root span ai.turn TypeError: Cannot read properties of undefined (reading '0') at AiStreamManager.onExecutionError —— 恰好在最需要 trace 的异常终止路径上丢了 trace。AiStreamManager.onExecutionError → AiTurnTrace 持久化。479d4a370a)+ 根因修正:原记录"异常终止路径"是采样偏差——真实根因是 developer mode 关闭时没有 TracerProvider,startSpan 返回 NonRecordingSpan(无 startTime),end 补丁无条件 convertSpanToSpanEntity → span.startTime[0] 抛 TypeError。每次 turn 结束都崩,与 outcome 无关,只是 WARN 淹没在正常日志里、异常终止时才被注意到。修法双保险:AiTurnTrace end 补丁对无 startTime 的 span 直接 no-op 返回(不 convert 不写 sink);spanConvert.ts 补 startTime 防御(与既有 endTime 守卫同风格)。新增无 provider 用例:handle.end() 不 throw、sink 不被调。严重度:待确认
resolveTools 中 request.mcpToolIds 只有 undefined 才回落到 resolveAssistantMcpToolIds(assistantId);渲染端 composer 传空数组时,助手在 DB 里绑定的 MCP 服务器完全不进请求。实测两个新话题行为不一致:重启后 renderer 首个新话题带上了助手绑定的服务器,同一 renderer 会话内再新建的话题则为空选。9c349245e8)+ 前提修正:原记录的"渲染端传空数组"前提不成立——聊天 IPC schema 根本没有 mcpToolIds 字段,composer 的 MCP 选择器写的是助手级 mcpServerIds,聊天请求恒走 resolveAssistantMcpToolIds 回落。非确定性另有两源:① McpCatalogService.listTools cache-only——冷缓存返回 [] 只触发异步预热(空结果还有 5 分钟退避),重启后首个话题 vs 后续话题因此不一致;② 三层 mcpMode 缺省不一致(main 'manual'/'disabled'、shared DEFAULT 'auto'、renderer 'disabled')。修法:解析前对目标服务器 await warmToolsCache(server.id)(冷缓存不再静默空集);三层缺省统一为 shared DEFAULT_MCP_MODE = 'manual'。新增 resolveAssistantMcpTools 确定性测试。ToolLoopTerminalError(20 步工具上限)终止 —— 步数护栏,按设计。场景:16k 窗口(aihubmix::claude-sonnet-4-6,前两轮 gemini-2.5-flash),4 轮联网搜索(巴黎奥运金牌/C919 航程/SQLite 版本/珠峰高程)+ 4 轮禁搜索召回测验;devtools 捕获全部 24 个请求载荷于
.devtools/generations.json。
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(新装)。
搜索 4 轮后估算过线,durable 压缩在 turn 开始触发一次(边界行写入 2,391 字符摘要;耐人寻味:边界行是一条 error 消息,收尾时仍被正确选中)。devtools 实测出站 prompt 从 28KB 降到 9KB(-68%);之后 8 个请求全部复用同一摘要行,无重复摘要化调用(对照 in-loop 的每步重折叠,durable 是 write-once-serve-many)。
压缩启用后连续 9 个成功请求的 Anthropic usage:每步 noCache 仅 1-3 tokens,cacheRead 3,087→4,604 递增,cacheWrite 只覆盖新增后缀——摘要行渲染 + 全量 web 结果在多轮间字节完全稳定,provider 前缀缓存几乎满命中。Gemini 两轮的隐式缓存命中率约 60-80%。
性质:改进方案(承接补充测试结论 1;web_fetch 建议尽快做)
[{id:'<prefix>-<n>', title, url, content}] JSON 数组(webLookup.ts mapResponse),模型靠"看见的条目"回写 [cite:id]。截断器对 json 是 stringify 后掐 head/tail——cite id 与 content 的映射会被从中间切碎,模型对未见条目要么弃引(信息损失)要么幻引(更糟)。src/renderer/utils/message/citations.ts 的 citation registry 就地从 message.data 的工具输出 parts 解析("no persisted reference metadata")。输出被信封替换后,历史消息的角标/来源卡/导出/复制全部失解。web_fetch 的 readable content 无工具级上限(长文档页 50-200k chars),是目前唯一完全裸奔的上下文洪水源;search(max_results=5×snippet)实测单轮仅 3-6k chars。chat.web_search.compression(postProcessing.ts,cutoff/rag)存在但默认 none。shape:'json'(#3)配 citation-aware 信封(保留 {id,url,title} 骨架)。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 解析解决。
根因一句话:现在的截断把工具输出当不透明字节流(extractText → head/tail),而工具输出实际是有结构的——身份字段(id/url/title)+ 大体积内容字段。字节级掐断毁结构,整工具豁免弃防线;布尔
truncatable表达不了"裁内容、保骨架"。根本修复 = 把裁剪单位从字节流改成实体的内容字段。
// ToolEntry 上替代布尔 truncatable 的声明(与工具 schema 同文件,单一事实源)
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:
entities 形——骨架 = [{id,url,title}],blobs = 各条 content。超阈值只裁单条 content 为 head/tail + marker,引用骨架在 prompt 与 DB 双侧永不有损。json-text-field 形——骨架 = {kind,startLine,...},blob = text 字段。fs_read 保留 in-flight 豁免(防循环),但 persist 侧裁回声;整页读回的 blob 经 contentHash 去重直接命中它刚读的那个 entry,零额外存储。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 }]
}
每个 blobRef 一条 tool_output file ref(一消息多 ref 已支持);fs_read 的 per-request allow-list 收集全部 blobRefs 路径;ai.tool.get_result / 渲染端展开走 inflate。
extractPersistableText/truncator extractText 双实现合并);| 阶段 | 内容 | 消化的问题 | 状态 |
|---|---|---|---|
| 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 声明)、packages/aiCore/src/core/context/truncator.ts(改查 codec)、src/shared/ai/transport/persistedToolOutput.ts(信封扩展,守卫向后兼容)、src/main/ai/streamManager/persistence/trimToolOutputs.ts + src/main/ai/messages/persistedOutputRendering.ts(走统一原语)、src/main/ai/tools/webLookup.ts / FsReadTool.ts(codec 定义)、src/renderer/utils/message/citations.ts(skeleton 解析,预期零改动)。