Claude Code 测试日志太长怎么办?我检查了 Context Diet 的错误保留边界
在 Claude Code 里跑 npm test 或 pytest,有时最烦的不是测试失败,而是失败被几百行 PASS、编译信息和警告淹没了。Agent 要么再跑一遍命令,要么重新读日志;如果只给它最后几十行,又可能漏掉真正的报错位置。
最近我从 Hacker News 的一条 Show HN 看到 Context Diet。它用 Claude Code 的 Mod Hook 把长工具输出改成较短的摘要,同时留下完整日志的文件路径供 Agent 回读。这个方向很合我意:减少重复读取,比单纯告诉模型“节约 Token”更像是一个能落地的工程办法。
但读到 README 里的 “every failure” 时,我开始犹豫。输出既然有长度预算,当很多不同的错误同时出现,真的能保证一个不漏吗?我把研究版本固定在 v0.3.0 对应的 commit 689284a,读了压缩器、Hook 和测试源码,又用 Node.js 跑了两组人工合成的错误日志。结果有点值得警惕:一个居中的关键错误能够被保留,但 145 条不同错误挤进较小的摘要预算时,很多错误行就不会显示在摘要里。
先说明范围:我没有安装并运行 Context Diet Mod,也没有进行 Claude 模型 A/B 测试。 后面的数字来自对通用日志压缩逻辑做的源码适配合成测试,不能当成插件在真实项目里的丢错率或 Token 节省率。
它改变的是工具返回给模型的那段文字
在这份固定版本中,Context Diet 主要处理 Bash、Grep、Glob 和 JSON 类 MCP 工具结果。通过 session.append,它在长输出进入对话上下文时选择性地提供摘要;Read、Edit、Write、WebFetch 和非 JSON 的 MCP 文本不在这条压缩路径里。源码中的默认触发阈值是 8,000 个字符,这里的单位是字符,不是 Token。具体实现可看 Hook 注册代码;Mod 机制本身属于 Claude Code 插件体系。
它并非用同一种方法裁剪所有东西。识别出 Jest、Vitest、pytest、Go test、Cargo、TypeScript 检查器等输出时,会尝试保留失败块、错误位置和摘要,折叠大量通过的测试;普通日志则寻找错误、警告和首尾;JSON 留结构与少量数组项;Diff、Grep、文件列表也有各自的规则。这些是固定源码中的解析策略,不是我在 Claude Code 里逐一跑出来的兼容性结果。
有一个设计我很赞同:摘要会告诉 Agent 完整输出在哪里。固定版本里,默认压缩后的原文放在项目的 .context-diet/out-NNN.txt,文件名在 300 个槽位之间轮换。写文件失败时,Hook 选择让原始结果直接通过,而不是返回一个指向不存在文件的漂亮摘要。原始数据有了回读路径,模型看到的短文本才更接近“索引”,而不是不可追溯的事实替代品。
不过“本地保存”不等于“长期存档”。旧槽位会复用;日志里的密钥、环境变量和项目路径仍可能留在磁盘上。.gitignore 能降低误提交风险,却不是加密或备份策略。要靠这些输出排查生产问题,重要的原始日志最好另有独立保留方式。
错误优先,也有塞不下的时候
我先去看了 diet.ts 里的通用日志压缩器。它会先折叠重复或形状相似的行,再给日志头尾留出一部分预算,随后逆序寻找错误片段——较靠后的错误优先。找到错误时,还会尝试带上前后几行上下文。
关键不是它怎么排序,而是紧跟着的一道长度判断。源码有这样一个分支:
const cost = window.reduce((n, i) => n + (folded[i]?.text.length ?? 0) + 1, 0)
if (used + cost > target) continue
这里的 continue 很朴素:一段错误及上下文放不进剩余预算,就跳过,继续找其他片段。较少错误时,结果可能很实用;碰到几十个不同的失败、每个还带着堆栈时,摘要没有办法同时容纳全部细节。
要注意,Jest、pytest 等被识别的工具走的是另一套专用解析器,按标记等级争夺有限长度预算。我这次验证的是通用日志路径,没有验证这些专用解析器的实际遗漏情况。不能用通用日志的数字直接下结论说“Jest 插件一定会漏掉多少条”。
README 把摘要描述成会保留每个失败,但从代码逻辑看,更准确的说法应该是:尽可能优先让失败可见;无法显示的内容仍需要从原文件读取。 如果把摘要里没看到 ERROR 直接当成“没有错误”,那就把一种有损预览误当成了完整检查结果。
我造了两种日志,观察它会留下什么
为了先验证这个预算边界,没有下载整套 Claude Code 或尝试使用账号。我把固定源码里的通用 dietLog 及相关文本处理函数手动适配为 JavaScript,用 Node.js v22.16.0 标准库在隔离环境运行。四项合成测试全部通过,进程退出码为 0。它们测的是压缩算法对人工输入的行为,不是第三方 Mod 在 Claude Code 里的端到端功能。
| 人工合成输入 | 原文长度 | 摘要长度 | 重要结果 |
|---|---|---|---|
| 冗长日志中间只有一条关键错误 | 32,087 字符 | 1,175 字符 | 1/1 个错误标记保留 |
| 145 条内容不同的错误,摘要预算 1,450 字符 | 23,146 字符 | 1,419 字符 | 7/145 条原始错误行可见 |
| 很短的输出 | 45 字符 | 45 字符 | 内容未变 |
PASS errorHandler.test.ts 等分类对照 | 52 字符 | 52 字符 | 通过的测试名没被误判为错误 |
这里的 1,450 字符是刻意收紧的合成实验预算,不是插件默认配置。在固定 v0.3.0 中,默认触发阈值为 8,000 字符;普通长输出的摘要目标是 Math.max(2_000, Math.round(threshold / 2)),即 4,000 字符,而 Claude Code 已经保存到文件的输出使用 3,000 字符目标。预算变大通常可以容纳更多错误,但两种默认路径和这次合成实验都不能据此得出真实插件的错误遗漏率。
第二组里有 138 条错误行没有出现在可见摘要。下面这段来自实际保存的合成摘要,省略号与被裁掉的行数也是生成结果的一部分,不是事后写的示意报错:
… [225 lines cut] …
ERROR CASEei unhandled category_ts in /app/modules/amy/workflow.ts:40 expected stable result bci
ERROR CASEej unhandled category_tt in /app/modules/amz/workflow.ts:40 expected stable result bcj
ERROR CASEek unhandled category_tu in /app/modules/ana/workflow.ts:40 expected stable result bck
…
… [79 lines cut] …
这里还有一个小细节:压缩器给出的说明是“5 of 145 error lines with context”,但直接数摘要里出现的原始错误行是 7 条。原因是日志头部或尾部预算本身也会留下错误行,不能把“专门选中的错误窗口数”当成“最终可见错误总数”。如果用这类计数做告警或自动验收,很容易产生误读。
我又把预算选择逻辑缩成一个只依赖 Node 标准库的最小测试文件,保留同样的 145 条不同错误场景,用 node --test budget-replay.test.mjs 复跑了两项测试,2/2 通过。这个更窄的对照保留了 8/145 条,比完整适配样本多一条,原因在于两份夹具和折叠处理范围不同。这两个数字不应被混算成一组性能对比,更不能说真实项目“会丢 95% 的错误”。
下面是已复测的最小重放脚本的完整代码,只模拟通用日志的预算选择分支,并没有调用 Claude Code 的 Mod API。保存为 budget-replay.test.mjs 后,运行 node --test budget-replay.test.mjs 即可复现 8/145 的结果:
/**
* First-party negative control for Context Diet v0.3.0's generic dietLog budget path.
* Pin: drkokorev/context-diet @ 689284a893f1aa509b9c6c1c06b91ef1ca52279c.
* Only the error-window budget logic is mirrored, for controlled ASCII log lines.
* This is NOT an upstream Mod invocation, a benchmark, or a production loss rate.
* The full adapted dietLog fixture and observed digests are in the separate evidence bundle.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
const id = i => { let s='',n=i+1; while(n){ n--;s=String.fromCharCode(97+n%26)+s;n=Math.floor(n/26); } return s; };
const noise = n => Array.from({length:n},(_,i)=>'step '+id(i)+' completed '+id(i+1200)+' '+i);
const critical = line => /^ERROR\b|^\s+(Expected|Received):/.test(line);
function select(raw,target){
const folded=raw.split('\n').map(text=>({text,level:critical(text)?2:0}));
const errorHits=folded.map((l,i)=>l.level===2?i:-1).filter(i=>i>=0);
const keep=new Set();let used=0;
const headBudget=target*(errorHits.length?0.1:0.2);
const tailBudget=target*(errorHits.length?0.25:0.35);
for(let i=0;i<folded.length&&used<headBudget;i++){keep.add(i);used+=folded[i].text.length+1;}
let tailUsed=0;
for(let i=folded.length-1;i>=0&&tailUsed<tailBudget;i--){
if(!keep.has(i)){keep.add(i);tailUsed+=folded[i].text.length+1;}
}
used+=tailUsed;
let windowsShown=0;
for(let h=errorHits.length-1;h>=0;h--){
const at=errorHits[h];const window=[];
for(let i=at-2;i<=at+3;i++)if(i>=0&&i<folded.length&&!keep.has(i))window.push(i);
const cost=window.reduce((n,i)=>n+folded[i].text.length+1,0);
if(used+cost>target)continue; // EXACT budget guard from upstream dietLog.take()
window.forEach(i=>keep.add(i));used+=cost;windowsShown++;
}
return {visible:folded.filter((_,i)=>keep.has(i)).map(l=>l.text).join('\n'),totalCritical:errorHits.length,windowsShown};
}
test('single late/central failure remains visible',()=>{
const text=[...noise(310),'ERROR ECASEONE impossible transaction transition',' Expected: completed',' Received: pending',...noise(310)].join('\n');
const out=select(text,2000);
assert.ok(out.visible.includes('ECASEONE'));
});
test('many distinct failures cannot all fit finite digest budget',()=>{
const failures=Array.from({length:145},(_,i)=>'ERROR CASE'+id(i)+' unhandled category_'+id(i+400)+' in /app/modules/'+id(i+900)+'/workflow.ts:40 expected stable result '+id(i+1300));
const raw=[...noise(90),...failures,...noise(90)].join('\n');
const out=select(raw,1450);
const retained=failures.filter(line=>out.visible.includes(line)).length;
assert.ok(retained>0&&retained<failures.length);
process.stdout.write(JSON.stringify({failure_lines:failures.length,retained,missing:failures.length-retained,digest_budget_chars:1450,mode:'source-budget-window-control-not-plugin-runtime'})+'\n');
});
上面的代码对已验证的预算机制做了适度精简,供读者复查有限预算会漏掉某些错误这个结论;它和完整适配压缩器的夹具不是同一份代码,具体保留数量取决于合成输入、上下文窗口和折叠方式。执行命令的退出状态能说明测试断言通过,不代表任何真实 Claude Code 插件运行成功。
这一轮实验让我改变的判断不是“压缩没有价值”,而是使用它时必须始终保留一个退路:失败位置可以优先展示,但不能仅凭摘要判断所有失败都已展示完毕。
我会先用 dry 模式,再决定要不要 capture
如果你已经在使用支持 Mod 的 Claude Code,可以按项目 README 的固定版本说明评估安装。作者当时要求 Claude Code 2.1.287 或更高版本,并提供以下命令;我没有在自己的 Claude Code 会话里验证安装过程,真实可用性需要以你当前的客户端和新版文档为准。
/plugin marketplace add drkokorev/context-diet
/plugin install context-diet@context-diet
我更建议先用 /diet dry,跑平时会产生大量输出的测试,再用 /diet report 看哪些结果本来会被裁剪,以及是否经常需要重新打开完整文件。dry 模式的设计是只统计、不改变发送给模型的原始结果。对某些必须完整审计的构建或发布命令,可以用 /diet keep <命令片段> 让其结果保留原样;或者直接 /diet off 关闭。
有一种情况需要单独说:Claude Code 自身可能先截断失败命令的长输出,插件收到的只剩不完整片段。作者提供了默认关闭的 /diet capture on,尝试在某些单条测试或构建命令执行时先把完整 stdout/stderr 存到 .context-diet/run-NN.log,再做摘要。
它不只是改个显示选项。源码里的 captureCommand 会包装符合条件的 Bash 命令,采用重定向、合并输出流和退出码回传。这样一来,原先的 Bash(pytest:*) 等权限规则可能匹配不上,被要求再次授权;管道、多命令串联、重定向和 watch 模式也被排除在自动捕获之外。对有严格权限或依赖实时终端输出的任务,我不会不经检查就开启它。用完可通过 /diet capture off 恢复默认行为。
原作者还给出了一组自测数据:七道问答任务里两组都答对,工具调用从 13 次变成 8 次;另外 18 次 Agent 修复运行中,平均工具调用由 8.0 次降到 6.0 次,启用 capture 后为 5.8 次。它们能说明作者测试时观察到了什么,但样本有限,我没有拿到足够的逐次原始记录,也没有独立复算模型行为、Token 成本或成功率。
这里最后留一个实用判断。如果你遇到的是大量通过记录反复占据上下文,Context Diet 值得尝试;如果你要做故障复盘、审计、复杂并发错误排查,摘要只能用来定位,最终结论应该回到完整日志。它的可取之处在于尽量减少无关信息,却仍允许你回到原文。至于它能否在真实 Claude Code 会话里稳定减少工具调用、是否会让某类测试输出误判,我暂时没有证据给出统一答案。
之前我研究过 CLAUDE.md / AGENTS.md / Skills 的常驻指令分层 和 Agent 编排中的状态交接。那些问题关心的是应该让 Agent 加载什么、让哪个 Agent 负责什么;这次关心的更小一些:模型眼前的一份工具结果,究竟是一份完整证据,还是只是一张可以返回原文的便签。