前几周我在排查一个跨模块的线上异常,日志落在A终端,代码在B编辑器,依赖关系散在几个仓库的README里,还有一屏接一屏的上游接口文档。来回折腾了半小时,我发现大多数时间根本不是花在找问题上,而是花在“把上下文从各个角落抠出来、再拼回到同一个脑子”上。后来我把这个思路做成一个小项目,名字就叫 context-mode——一个把散落上下文规整到同一视图、并能让大模型直接消费的终端工具。这篇文章就是它的拆解笔记。
说得更直白一点:context-mode 解决的从来不是“看不懂代码”的问题,而是“来不及看清代码和它周围那一圈关系”的问题。它适合谁?适合日常要在终端、编辑器、日志、git 历史之间反复横跳的开发者,也适合那些正在做 AI 编程辅助、想让大模型拿到真正有用上下文的人。下面我会从设计动机、核心协议、关键实现、实测过程和踩坑记录五个方面展开,尽量把能直接抄作业的部分都摊开讲。
1. 从“上下文割裂”到“上下文即服务”:Context Mode要解决的问题
1.1 一次排查任务的真实时间账
先说一个我记录过的真实场景。某天下午,我接到一个任务:某个服务在特定参数组合下返回了错误结果。排查链路是这样的,先在日志平台里按 traceId 捞出报错时刻的调用栈,再去代码仓库里定位那几行日志所在的服务,接着发现这个服务依赖了一个内部 SDK,于是又去翻 SDK 的版本变更记录,最后还要对照配置中心的开关状态。
这一步一步拆开,每件事本身都不难,但每件事之间都有“上下文切换成本”——你要记住刚才那个 traceId、记住报错栈停留在哪个函数、记住 SDK 新旧版本的行为差异,然后才能跳到下一个窗口。我把经常被忽略的时间算了一笔账:单次切换大约需要 20 到 40 秒找回状态,而一次中等规模的排查往往要切换 20 次以上,光是找回状态就耗掉了 10 到 15 分钟。真正和问题搏斗的时间反而只有一小半。
1.2 现有方案为什么治标不治本
最原始的做法是开一堆终端标签页,把有用的输出贴到一个临时 md 文件里。问题很明显:贴完就忘,文件越来越多,等到真要回头找的时候,还要靠文件名猜内容。稍微进阶一点的做法是用笔记软件手动维护,比如把某个模块的架构说明、报错记录、相关命令都放到一篇笔记里。这种方式对长期知识积累有效,但对“正在发生的事”几乎无能为力——你可不会在排查到一半的时候,停下来手工把刚刚那 200 行滚动日志整理成一篇优雅的笔记。
更常见的还有一类工具:在编辑器里装插件,把当前文件、最近打开的文件、选中内容自动打包发给 AI。这种方案对“单文件级”的小任务挺好用,一旦问题跨越多个服务、涉及运行态日志和配置状态,它就又不够了。说到底,痛点不是“没有信息”,而是信息散落在不同的运行时环境里,缺少一个轻量的、随取随用的聚合层。
1.3 context-mode的定位:把上下文做成可查询的资产
所以我当时的想法是:与其人肉搬运上下文,不如让工具主动去“采集”上下文,再统一成一种结构化格式。context-mode 就是一个运行在终端里的常驻工具,它可以监听当前工作目录、git 状态、最近的终端输出、日志文件尾部、项目配置等信号,把有价值的信息聚合成一张实时更新的上下文视图。
关键点在于,它不只是给人看的,也是给机器看的。视图底层是一组 JSON 记录,每条记录都带有来源、时间戳、优先级和 Markdown 格式的内容。这样一来,人可以按快捷键打开面板浏览,大模型也可以通过标准输出读取这些记录,从而在回答问题时获得比“单纯丢代码”可靠得多的背景信息。这就是我理解的“上下文即服务”——不再靠人肉复制粘贴,而是让上下文成为可以查询、可以过滤、可以按 Token 预算裁剪的资产。
2. 核心架构:上下文采集、归一化与聚合的管道设计
2.1 数据源接入层:哪些信息值得进上下文
context-mode 的第一步是定义数据源。不是所有信息都值得采集,进上下文的东西至少要满足三个条件:与当前任务相关、能帮助决策、不会频繁制造噪音。我目前实现的采集器大概可以分成四类:
- 版本状态类:git 分支、当前 diff、最近 5 条提交、子模块状态。这类信息回答的是“代码处在什么状态”。
- 运行观察类:日志文件尾部、进程状态、最近执行的命令。这类信息回答的是“运行现场发生了什么”。
- 结构配置类:项目配置文件、服务注册信息、环境变量白名单。这类信息回答的是“系统是怎么被配置出来的”。
- 知识引用类:当前目录下的 README、接口文档摘要、测试报告摘要。这类信息回答的是“项目自己是怎么描述自己的”。
每一类采集器都实现同一个接口,核心方法就两个:Collect()负责抓原始数据,Normalize()负责把原始数据转成统一的上下文记录。这样后续新增数据源只需要写一个采集器,不需要改动上层逻辑。
2.2 统一上下文协议:一条Context Record长什么样
采集器产出的记录我定义了一个很朴素的协议,一条 ContextRecord 大概长这样:
{ "id": "ctx-8f21a", "source": "git-diff", "scope": "cwd", "priority": 0.85, "timestamp": "2025-11-23T14:32:10Z", "tags": ["backend", "billing"], "content_markdown": "## 变更概览\n- 文件: internal/billing/processor.go\n- 新增 23 行, 删除 4 行\n- 可疑点: 对空账户的默认折扣未做判断" }每条记录都带priority,范围是 0 到 1,表示这条上下文对当前任务的“推荐程度”。这个字段非常重要,后面所有按 Token 裁剪、按重要度排序的逻辑都以它为锚点。content_markdown是给人看的渲染内容,id和source则用来做去重和溯源——你拿到一条结论后,永远能顺着source找到它来自哪个数据源。
为什么用 JSON 而不是直接拼文本?原因有两个。第一,JSON 保留了结构,后期想做过滤、排序、按标签聚合都很容易;第二,大模型消费 JSON 比消费一段杂乱文本更稳定,它能直接感知到“这是经过整理的信息片段”,而不是被塞进一堆控制字符里。
2.3 去重与优先级:上下文不是越多越好
这里有一个我一开始没想明白、后来吃了亏才记住的教训:上下文数量绝不是越多越好。刚开始我把采集周期设得很短,git diff 每次全量读,终端历史取最近 100 条,结果面板密密麻麻全是信息,真正有用的一条被淹没在里面,大模型的回答质量反而大幅下降。后来我补了两道闸门。
第一道是去重。同一文件在同一 git 状态下反复生成的内容,只保留最新一条;终端历史里重复出现的命令只保留第一次和最后一次;日志尾部滚动出来的相似异常行会先聚类再抽样,避免几十条一模一样的报错把上下文撑爆。
第二道是优先级衰减。任何一条上下文的priority会随时间衰减,比如 5 分钟内是原始值,超过 10 分钟乘以 0.6,超过 30 分钟乘以 0.2。衰减因子也可以被“主动唤醒”事件重置——比如你切换到某个分支,那么 git 类上下文就重新获得高优先级。这套机制保证了上下文面板始终像一个“保持新鲜的现场速写”,而不是一本越写越旧的手册。
3. 关键实现细节:从采集到可读视图的完整链路
3.1 采集器实现:Git Diff、终端历史、日志尾部的处理差异
不同数据源的落地上差别很大,挑三个最有代表性的展开说。
Git 状态采集是相对简单的。直接调git diff --stat和git log --oneline -5,再配合git diff读取具体变更内容。难点在于 diff 可能非常大,不能全量塞进上下文。我的做法是先看统计行,如果变更文件数量超过 10 个或者总行数超过 300,就只保留每个文件的“更改摘要 + 关键函数附近的内容”。关键函数怎么找?用正则匹配到新增行里包含func、def、class之类关键字的片段,把这些片段单独提取出来。这样既保留了指路信息,又控住了 token 量。
终端历史采集比看起来麻烦。你不能直接翻 shell 的 history 文件,因为用户可能同时开了多个标签页。我的方案是监听一个内部的命令钩子:每次用户在 context-mode 启动的终端会话里执行命令,工具就把命令和输出摘要记录到自己的事件表里。输出摘要怎么生成?如果输出超过 80 行,只保留前 20 行和最后 20 行,中间用一条标记代替。这样既能看到命令的启动状态,也能看到结束状态,还不至于把 500 行编译日志全吞进来。
日志尾部采集则有它自己的节奏。日志是不断滚动的,不能每次全量读,否则重复条目迟早把存储打爆。我采用两段式策略:先按文件 inode 和读取偏移量做增量读,再把新增行按相似度聚类。聚类的逻辑很简单,去掉数字、IP、时间戳之后如果文本相同,就归为同一簇。每簇保留第一条完整内容,之后只累计次数。你最后看到的是这样一条记录:“该异常在最近 5 分钟内出现 37 次,首次出现于 14:31:02,内容为……”。
下面这段 Go 代码是采集器接口的大致形态,实际实现时主要把每个数据源的差异封装在snapshot方法里:
type Collector interface { Name() string Snapshot(ctx context.Context) ([]ContextRecord, error) Reset() } type GitCollector struct { repoPath string lastDiff string } func (g *GitCollector) Snapshot(ctx context.Context) ([]ContextRecord, error) { statOut, _ := exec.Command("git", "diff", "--stat").Output() diffOut, _ := exec.Command("git", "diff").Output() summary := summarizeDiff(string(diffOut)) return []ContextRecord{{ ID: "git-" + hash(statOut), Source: "git-diff", Priority: 0.9, Content: summary, }}, nil }3.2 Markdown卡片渲染与交互布局
上下文记录收集好之后,需要一种让人舒服的消费方式。context-mode 的界面没有走复杂路线,就是一个全屏终端面板,顶部是当前工作目录和采集状态,中部是上下文卡片流,底部是过滤输入框。每条卡片渲染为一块 Markdown 区域,左上角标注来源和优先级,正文字体略小,支持折叠。
交互上我设计了几条快捷键:j/k上下移动,enter展开完整内容,f进入过滤模式可以按来源或标签过滤,r手动刷新所有采集器。这里有一个很实用的细节:按下y可以复制当前卡片的原始 JSON,这样在需要把某条上下文贴到聊天窗口时特别顺手,不用再手选大段文本。
渲染层的坑在后面的踩坑章节会详细展开,这里只提一句:Markdown 渲染必须做“消毒”处理,因为采集到的日志、命令输出里面很可能混入控制字符,直接喂给渲染器轻则乱码,重则让终端 UI 直接崩掉。
3.3 与LLM协同:Token预算下的上下文投喂策略
context-mode 最有意思的部分是它可以当作大模型的“上下文外挂”。使用方式很简单:以 JSON 形式把当前上下文集序列化到标准输出,然后把它拼进 system prompt。但直接全量拼接不可行,所以实现了一个--budget参数,用来控制投喂给模型的上下文总量。
我按 Token 估算做了三级裁剪策略。预算充足时(比如 8000 Token),投喂高优先级的完整 Markdown 内容、中优先级的摘要内容,跳过低优先级;预算紧张时(比如 2000 Token),只投喂前三条高优先级卡片,并各自压缩到 200 字以内;预算未知或读取失败时,则默认走“标题 + 来源”的清单模式,让模型知道有哪些上下文可用,再让它按需提问。
这里需要一张我实测下来的预算参考表:
| Token预算 | 投喂策略 | 典型效果 |
|---|---|---|
| 8000以上 | 高优先级全文 + 中优先级摘要 | 模型能给出具体修改建议和定位链路 |
| 4000~8000 | 前5条高优先级全文 | 能定位大致模块,细节建议较少 |
| 2000~4000 | 前3条压缩摘要 | 只能做方向性判断 |
| 2000以下 | 标题 + 来源清单 | 适合让模型问你要哪块上下文 |
Token 粗估我用的是一个非常保守的算法:英文字符数除以 4,中文字符数直接计数,代码行额外加 10% 的符号开销。这个算法在开源模型上的误差大约在 15% 以内,实战中够用了。
4. 实测记录:一个线上问题的完整排查链路
4.1 排查场景与前置条件
光讲设计难免空,我挑一次真实记录过的排查过程来说明 context-mode 实际是怎样工作的。某天下午,模拟项目 A 的订单服务在特定用户分组下返回了“余额不足”的错误,但用户后台明明有余额。这个任务跨了三个模块:网关层负责组装请求,订单服务负责扣款,账户服务负责余额校验。
排查前我先做了两件事:把工作目录切到订单服务仓库,启动 context-mode 后让它静默采集了大约三分钟。这三分钟里,工具自动读取了 git 状态、最近命令、日志文件和项目配置。
4.2 context-mode输出的实际上下文快照
三分钟后我按下快捷键展开面板,看到的内容大致是这几条高优先级记录:
第一,git 状态显示当前分支有一个未合入的提交,提交信息是“调整账户余额校验顺序”,改动集中在账户服务的校验函数和订单服务的调用处。第二,日志采集器抓到了 11 次同类报错,报错详情指向账户服务返回码 10086。第三,配置采集器发现该用户分组其实命中了“白名单模式”,而这个模式下的余额校验逻辑走的是另一套分支。第四,终端历史显示操作者在十分钟前刚执行过一条手工 SQL,看起来是想绕过应用层直接查账户表。
这四条信息单独看都很普通,但拼起来之后,问题基本就浮出水面了:代码提交调整了校验顺序,但白名单模式的老分支没同步更新,于是部分用户被旧代码拦截。整个定位过程从开始到有结论大概五分钟,其中真正花在“看上下文、拼线索”上的时间不到两分钟。如果按老办法,我大概率要到第六七个窗口切换之后才会注意到那个白名单配置。
4.3 效果对比与经验总结
我后来把同类型排查任务做了一次简单计时。不使用 context-mode 时,从接到报错到给出初步定位结论,平均耗时在 25 分钟左右,其中大概有 20 分钟是在不同窗口里翻日志、看代码、对配置。使用 context-mode 后,同样的任务平均耗时降到了 8 分钟,其中大半时间是在验证结论而不是找线索。
这个差距背后的原因很好理解:工具把“数据获取”和“数据处理”这两件事解耦了。人只需要做判断,不需要做搬运。但我也要说句公道话,context-mode 不是万能的。它最适合的其实是“现场型”任务——正在发生的报错、刚改完的代码、热乎的日志。对于那种需要理解半年历史演进、翻遍设计文档的任务,它只能做入口,不能替代完整阅读。
5. 上线后踩过的坑与后续优化方向
5.1 控制字符与超长行:终端内容的现实毒打
第一版上线没多久,我就遇到了一个特别基础的坑:日志文件里有 ANSI 颜色转义序列,渲染层直接把整段内容当成 Markdown 正文,结果面板出现一片片微不可见的乱码,折叠展开时还会闪屏。更严重的是某些日志行极长,单行 2 万字符,Markdown 渲染器处理这种行时会卡住整个 UI。
解决方案分两层。第一层在采集端,所有从终端、日志、命令输出拿到的原始文本,统一经过一个清洗函数,把\x1b[...m之类的控制序列全部剥掉,同时把单行超过 500 字符的文本强制截断并添加省略标记。第二层在渲染端,引入一个轻量 Markdown 白名单引擎,只允许标题、列表、粗体、行内代码、代码块这几种语法,其余标签一律转义。经过这两层处理之后,面板稳定了很多,至少不会再被一段日志搞崩。
5.2 敏感信息过滤与权限边界
上下文采集天然带着一个隐患:它把那么多数据源聚到一起,意味着权限风险也被聚集了。git diff 可能包含密钥文件变更,日志可能包含用户手机号或邮箱,终端历史甚至可能包含临时输入的密码。刚开始我完全没做过滤,直到测试时发现某次生成的上下文里带出了一条数据库连接串,这才意识到必须把安全机制前置。
目前实现的过滤规则分成三类。第一类是指定模式规则,比如匹配AKIA、password\s*=、BEGIN PRIVATE KEY的模式,命中后整行打码。第二类是文件路径规则,.env、*.pem、id_rsa这类文件默认不进入采集范围,即使它们出现在 git diff 里也只保留文件名。第三类是命令规则,mysql -p、ssh -i这类高危命令的历史记录默认只保留脱敏后的命令名,不保留参数。每次生成上下文快照时,还会额外跑一遍 Scan 函数做最后检查,确保传递给大模型的每一批内容都不含明文敏感字段。
5.3 性能与缓存优化
采集器容易越加越多,性能要是不管,几分钟就能把磁盘和 CPU 吃满。我遇到的第一个瓶颈是 git diff:在大型仓库里,全量 diff 一次可能要跑几十秒,导致其他采集器排队。解决办法是给 diff 结果加了一层 Git 对象缓存,只有在 HEAD 变化时才会重新计算。
第二个瓶颈是日志文件轮转。有些服务的日志是按小时滚动切分的,如果采集器一直盯着旧文件的 inode,会发现文件内容不再增长,但磁盘占用却越来越少。为此我加了一个文件匹配逻辑,按文件名模式自动切换到最新的文件,同时避免重复读旧文件。第三个优化是并发控制,采集任务拆到独立的 worker pool,最多同时跑 4 个采集器,但渲染 UI 永远优先响应。整体优化之后,常驻内存控制在 80MB 以内,CPU 占用在中型仓库上通常不超过 3%。
5.4 可以继续扩展的方向
context-mode 目前的状态对我个人来说已经够用,但它还有很多明显的延展空间。我想记录几个值得做的方向,以后如果有时间会逐个实现。
第一个方向是“任务级上下文快照”。现在它只能反映当前工作目录的即时状态,如果能支持把某个时点的上下文整体存成快照,并打上任务标签,就能在中断几个小时后通过一条命令快速恢复现场,这对排查周期长的疑难问题会很有帮助。第二个方向是“上下文差异对比”。比如上线前后各采集一份上下文,工具自动对比两份快照之间的差异,并高亮可疑变化点,这相当于给变更管理加了一层自动化的“同行评审”。第三个方向是更细粒度的权限策略。比如在把上下文投喂给外部模型服务前,区分“允许模型读取的脱敏内容”和“仅本地展示的内容”,这样可以在不牺牲便利的前提下进一步控制敏感信息外泄面。
技术债方面,目前的采集器数量还不多,协议字段也比较粗,等接入的数据源种类多了以后,大概率需要引入更明确的 schema 版本管理,避免各数据源的字段漂移。这些都不算复杂,但需要等实际需求推动,不会一下就做完。
我自己用下来最有体感的收获,反而不是“省了多少分钟”这种量化指标,而是心态上的变化:以前每打开一个新终端标签页,心里都要默默重建一遍现场;现在只要看一眼 context-mode 面板,就能快速知道自己处在哪个位置、刚才做了什么、当前这个任务牵扯了哪些模块。它确实把“上下文重建”从一次疲惫的手工劳动变成了一件随叫随到的服务。如果你也在为多窗口切换苦恼,不妨按这篇文章的思路先搭一个最简版本——先接 git 状态和日志尾部两个数据源,跑通之后再慢慢扩展,你会有意想不到的收获。