
我最近把自己日常用的几个命令行工具挨个翻新了一遍统一加上了context-mode也就是“上下文模式”。折腾完最大的感受是以前每跑一条命令都要把背景信息从头到尾交代一遍现在工具能自己记住“我们正在处理什么、已经查到哪里、结论是什么”效率完全不是一个量级。这篇东西不是教程而是我实现这个模式时的完整记录包括设计思路、核心代码、实际效果和踩过的坑适合整天泡在终端里的运维工程师、写内部工具的后端开发以及正在尝试给命令行接入大模型能力的同学参考。1. context-mode到底解决什么问题一次教训逼出来的需求先说清楚我在做什么。我给一批命令行小工具日志检索、代码批量替换、文件重命名这类加上了会话级的上下文记忆能力。启用context-mode之后工具会记住同一个会话里之前输入过的查询、操作结果、用户的修正信息在下一次执行时自动带着这些背景去工作。听起来很美好但说实话这个需求的出发点并不是“想做一个很酷的功能”而是一次真实的工作失误。1.1 那个让我下定决心改工具的下午那天我要批量修正一批历史日志文件里的时间戳格式。我用的日志工具本身支持正则替换指令大概是这样的logtool replace --pattern created_at:([0-9]{4}-[0-9]{2}-[0-9]{2}) --replacement date:$1 ./logs/问题出在“日志根目录”这个概念上。我这个机器上有两套日志一套是测试环境每天生成的案例数据另一套是生产环境导出的历史归档。测试环境的日志在/data/test/logs/生产归档在/data/prod/archive/。工具默认用当前工作目录作为日志根目录。那天我刚好在测试环境的目录底下做另一个任务终端的工作目录切到了/data/test/logs/然后我迷迷糊糊地在同一个终端里直接执行了上面那条替换命令结果把测试环境的日志全部替换了一遍。事后回看这个锅一半在我一半在工具。工具是无状态的它不可能知道我这次操作的真实意图是“处理生产归档”。它只会按照参数里出现的信息去执行而我当时给的信息不完整路径前缀用的是./logs/它自然就按当前目录解析了。如果工具有上下文模式它应该知道这个会话从早上开始一直在处理生产环境的归档问题会检测到当前目录和会话上下文里记录的目录不一致然后向我确认。但当时没有这个能力错误就发生了。1.2 无状态命令行的三个隐藏成本这次踩坑让我认真盘算了一下无状态命令行的真实成本远不止一次操作失误那么简单。第一个成本是沟通成本。每次执行新命令你都得把项目的背景信息重新讲一遍。比如“我这个项目的日志格式是这样的”“代码库在哪个目录”“上一次已经过滤到的行号范围”。这些信息不是没有而是全都堆在你脑子里每次都要手动把它翻译成命令参数。命令一长转义、引号、特殊字符的问题全冒出来了。第二个成本是操作成本。很多任务天然是逐步递进的第一步先定位第二步再过滤第三步做统计。无状态工具逼着你在每一条命令里把前几步的结果重新想办法带进去。常见做法就是把上一步的输出存到临时文件然后用$(cat temp.txt)塞进下一条命令。这样做不仅慢还特别容易错。第三个成本最隐蔽是认知成本。工具不帮你记账你就得自己记。我经常同时开好几个终端窗口分别处理日志排查、代码重构、接口调试。每个窗口“进行到哪一步”全靠脑子硬记。一旦被电话打断回来就不知道刚才查到哪儿了。context-mode 本质上就是把这份“账”从人脑挪到工具里让工具自己维护一份“进行到哪里了”的记录。1.3 context-mode适合什么、不适合什么我先把这个模式的适用范围说清楚避免有人把它当成万能药。适合的场景有三个特征连续多轮、逐步收敛、依赖前一步的结果。典型就是线上问题排查先定位异常时间点再提取关联请求再回代码里查逻辑每一步都要用到上一步的输出。再比如批量代码迁移先让工具理解旧接口的调用方式再让它识别需要改的位置最后才做批量替换。不适合的场景也有三个特征一次性独立查询、结果需要严格复现、命令本身对上下文完全不敏感。比如我要统计一个目录里有多少个文件这种操作跟前后文毫无关系硬套上下文模式只会增加额外的读取开销和出错可能。再比如做正式的数据报表每一步都要求可审计、可复现那就不该依赖隐形的上下文而应该把每一步的入参出参都写清楚。2. 设计一个能用的context-mode会话、窗口和作用域缺一不可确定了需求之后我开始设计。刚开始我以为 context-mode 就是“把上一次输入的命令存下来下次自动带上”真正落地时才发现完全不是这么简单。一个能用的 context-mode 至少要解决三个问题用什么容器来装上下文、上下文保留多少、上下文在什么范围内生效。2.1 会话把“群聊”和“单聊”分开我第一个设计决定是引入“会话”session概念。你可以把会话理解成微信里的一个群聊。处理不同任务时各自开一个群群里的人和信息不互相干扰。实际的实现就是一个字符串 ID每个终端窗口、每个具体任务分配一个唯一的 ID。会话 ID 的生成策略我一开始用的是随机 UUID后来发现不好用。因为终端窗口一关UUID 就丢了下次想继续还得手动记。后来改成“基于工作目录 任务类型”的自动命名。比如在/data/prod/archive/目录下做日志任务会话名自动变成prod-archive-log在/work/project-a目录下做代码批量替换会话名变成project-a-code-refactor。这样终端关了也能凭名字找回上下文而且目录不同会话自然不同避免串味。2.2 上下文窗口哪些历史信息值得留下来第二个问题是保留多少历史。这里的核心矛盾是上下文越多信息越全但同时噪音也越多处理成本也越高。尤其当你把工具接到大模型上时token 窗口是硬约束。就算不接大模型纯做命令拼接历史过长也会导致命令灵活性和可读性下降。我最终采用了三层策略第一层近期交互全量保留。最近 8 轮的用户输入和工具输出原样留在上下文里这是最常用、最准确的信息来源。第二层远期交互摘要化。超过最近 8 轮但还属于本会话的内容不能直接丢弃否则用户问“昨天上午查到的错误码是什么”就查不到了。我会在每轮结束时让工具生成一条一句话摘要塞进一个独立的summary字段。比如“已确认超时集中在订单服务错误码为 TIMEOUT_503涉及 3 个接口”。第三层按字符数硬截断。上下文块超过 2000 字符时再往后的历史统一折叠成一行提示只保留“该部分的用时、结果类型”这等元信息。这套策略的原理很简单越近的信息越可信、越常用越远的信息只需要保留一个“指向性”的索引真需要细节时再主动检索。2.3 作用域规则防止上下文串味第三个问题是作用域。上下文应该跟着什么走如果所有命令都共享一份巨大的全局上下文那还不如没有。我定义了三种作用域目录作用域上下文默认绑定到当前工作目录。你在哪个目录执行命令就自动加载那个目录对应的会话文件。这解决了我开头那个事故的场景——工具应该感知当前目录并且检查它和会话里记录的目录是否一致。会话作用域通过显式的--session xxx参数指定。适合那种“我就是要跨目录处理一件事”的场景。例如排查一个跨服务的线上故障要在三个目录之间来回跑那就用同一个 session 把它们串起来。命令作用域不是所有命令都能读上下文。我维护了一份环境变量CTX_ALLOWED_COMMANDS只放行白名单内的命令比如grep、logtool、sqlite3、ast-grep。白名单外的命令一律不注入上下文避免把历史信息传给一个随意的系统命令。配置长这样# ~/.ctx/config.yaml scopes: - path: /data/prod/archive session: prod-archive-log window: 8 max_chars: 2000 - path: /work/project-a session: project-a-code-refactor window: 12 max_chars: 3000 allowed_commands: - grep - logtool - ast-grep - sqlite3这三个设计合起来才构成一个真正“能用”的 context-mode。会话提供容器窗口提供容量控制作用域提供隔离边界。缺一个整个模式都会在实际使用中翻车。3. 照着做即可用Python和SQLite实现一个轻量context-mode选型阶段我纠结过一阵。有人推荐直接用 JSON 文件存历史每会话一个文件简单直接。我测试之后放弃了原因有三个并发写入会互相覆盖历史一多读整个 JSON 文件再解析非常慢想按时间倒序查最近几条还得把全量数据读出来排序。SQLite 完美解决这三个问题而且它是 Python 内置模块零额外依赖。3.1 数据库结构和会话管理我建了两张表。一张存会话元信息一张存消息记录CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, workspace TEXT NOT NULL, summary TEXT DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN (user, tool, summary)), content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); CREATE INDEX idx_messages_session_time ON messages(session_id, created_at);sessions表里的workspace我强调一下它可以算整个设计的灵魂。每次写入消息时工具都会把当时的真实工作目录记录到workspace字段。后续执行命令前工具会校验当前目录与会话里的workspace是否一致。不一致就提示防止跨目录使用同一份上下文导致误操作。3.2 上下文注入的关键逻辑接下来是核心函数从数据库里拼出一段上下文文本。这是我的实现import os import sqlite3 from pathlib import Path DB_PATH Path.home() / .ctx / context.db def build_context(session_id: str, window: int 8, max_chars: int 2000) - str: conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row summary_row conn.execute( SELECT summary, workspace FROM sessions WHERE id ?, (session_id,) ).fetchone() if not summary_row: return lines [] if summary_row[summary]: lines.append(f[会话摘要] {summary_row[summary]}) rows conn.execute( SELECT role, content FROM messages WHERE session_id ? AND role ! summary ORDER BY id DESC LIMIT ? , (session_id, window), ).fetchall() lines.append([最近交互]) total_chars sum(len(line) for line in lines) for row in reversed(rows): block f[{row[role]}] {row[content]} if total_chars len(block) max_chars: lines.append(...(上下文已达上限更早的记录已折叠)...) break lines.append(block) total_chars len(block) lines.append(f[当前工作目录] {os.getcwd()}) lines.append(f[会话记录的目录] {summary_row[workspace]}) conn.close() return \n.join(lines)你看粗体的核心逻辑摘要放最前面、近期全量放中间、工作目录比对放最后。摘要保证远程信息可达近期全量保证最近几轮细节完整目录比对保证不会跨界使用上下文。这个顺序我是调了好几版才定下来的一开始把摘要放最后结果遇到长上下文时工具容易忽略摘要因为大模型对尾部内容的注意力通常不如头部。3.3 对接外部命令的三种注入方式上下文拼好之后怎么把它传给真正干活的命令我实验了三种方式各有适用场景。第一种是管道注入适合那些能从标准输入读取指令或数据的工具ctx build --session prod-archive-log | logtool replace --pattern created_at: --replacement date:第二种是参数注入适合自家开发的、支持--context参数的工具。这个最干净上下文作为一条独立参数传入不污染标准输入my-cli --context $(ctx build --session prod-archive-log) --query 统计今天各接口的错误数第三种是环境变量注入适合那些既不支持管道也不支持--context的老命令。我把上下文写进环境变量CTX_BLOCK然后在命令内部按需读取CTX_BLOCK$(ctx build --session prod-archive-log) sh -c echo $CTX_BLOCK /tmp/ctx.txt; grep 超时 /var/log/app.log第三种方式有个额外好处环境变量不会像命令行参数那样受ARG_MAX长度限制。我之前遇到过一次上下文太长用参数方式直接报argument list too long切到环境变量方式就没事了。3.4 自动写入工具输出的“记账”函数光能读上下文不够还得能写。我的做法是在工具执行完后把命令和结果一起记进 session。这个动作我封装成一个独立函数叫recorddef record(session_id: str, role: str, content: str): conn sqlite3.connect(DB_PATH) conn.execute( INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?), (session_id, role, content), ) conn.execute( UPDATE sessions SET updated_at CURRENT_TIMESTAMP WHERE id ?, (session_id,), ) conn.commit() conn.close()配合一个简单的包装脚本用起来大概是这样的ctx record --session prod-archive-log --role tool --content 已替换 132 处时间戳均符合预期到这里一个最简可用的 context-mode 就成型了。数据库存历史build 函数出上下文record 函数写历史目录比对防串味。核心逻辑不到 100 行 Python能跑而且好用。4. 实际跑起来的效果日志排查和批量改代码的真实体验工具写完后我在两个真实任务里用了将近一周。效果有惊喜也有需要注意的地方我分开说。4.1 线上接口超时排查8轮对话收敛到根因第一个任务是排查线上接口超时。这个任务以前的标准流程是先查错误日志定位时间点再去网关日志找关联请求然后回代码里看对应逻辑最后还要确认是不是资源竞争。四步操作每一步都要带着上一步的结论。用了 context-mode 之后整个流程变成了连续的对话。第一轮我输入“查看订单服务最近 30 分钟的 503 错误”工具返回了错误集中在 14:30 到 14:35。上下文自动记录了这个时间窗口。第二轮我直接说“提取这个窗口内所有订单请求”工具知道“这个窗口”是指上一轮的结果没有让我重复输入时间范围。到第七轮时工具已经根据上下文里的前后信息锁定了疑似问题代码位置。整个过程我除了第一次输入了比较完整的背景后面都是极简的短句。查完最后统计了一下总共 8 轮交互用时约 15 分钟。以前同样的排查光是反复复制粘贴时间戳和请求 ID 就要多花 10 分钟而且经常因为粘贴错了出岔子。4.2 批量代码迁移两条命令理解整个老项目第二个任务更典型。公司有个老项目里面 20 多处日期格式化直接调用了datetime.strftime现在要统一换成一个新的日期工具函数。以前我会写一个复杂的正则脚本先全局搜索再逐个核对调用上下文确认安全后替换。用 context-mode 的玩法是第一条命令让工具分析项目里strftime的调用模式把结果存进上下文第二条命令直接告诉它“按之前确认的模式把安全位置的调用全部替换”。工具读上下文时发现了一条关键信息老项目里strftime的调用有两类一类直接格式化用户可见时间一类格式化日志时间戳而新函数只兼容前者。它没有盲目替换而是在日志时间戳那类调用处停下来提醒我确认。这个提醒完全是上下文积累出来的——没有前面的分析步骤它根本不可能知道我项目里这两类调用的语境差异。4.3 改进前后的对比数据我用一个简单的表格记录了这个星期的实测情况任务原方式耗时context-mode耗时命令条数对比犯错的次数接口超时排查约35分钟约15分钟12条 vs 8轮原方式错2次新模式0次批量代码迁移约2小时约50分钟5个脚本 vs 2条命令原方式改错过1次新模式0次日志时间戳批量替换1次事故校验后发现目录不符并停止2条 vs 1条避免了原事故最关键的改进不是省时间而是避免错误。原模式下的错误大多数是“人脑记账记岔了”新模式下账本在工具里省掉了这部分人为风险。4.4 别把context-mode当成万能药三类效果不佳的场景用了一周我也发现了几个反例。第一类是纯统计类操作。比如“统计这个目录下有多少个文件”这种一次性的确定性操作加不加上下文几乎无差别反而多了一层 SQLite 读取和上下文拼装的耗时。第二类是命令本身有缓存或自身状态的场景。比如我接了个带内置历史功能的数据库客户端它和 context-mode 的记账机制互相猜疑导致上下文里出现了重复结论。第三类是人本身就没想清楚的任务。如果你自己都不知道下一步要干嘛句式再怎么依赖上下文也帮不上忙因为上下文只能放大你已表达的方向不能替你补全缺失的判断。5. 用久了才知道的坑上下文泄漏、腐蚀和并行会话冲突使用一周后我停下来专门梳理了踩过的坑。这五个问题几乎每个都是会在某个意想不到的时刻炸一下的我按严重程度排一下。5.1 敏感信息被带进上下文第一个问题最严重也最容易被忽略。日志文件、代码内容、环境变量这些东西被工具记进上下文后会被持久化到 SQLite 数据库里。如果数据库文件被同步到网盘、或者被其他同事打开敏感信息就等于裸奔了。更隐蔽的是当你把上下文输出传给大模型工具时相当于把项目中涉及内部路径、账号ID、错误详情等内容发送到了外部服务。我后来加了两道防护。第一道是写入过滤通过正则黑名单把疑似密钥、token、内网 IP 地址替换成[FILTERED]。第二道是审计日志每次上下文被读取都会记一条记录包含时间、会话ID、读取的命令。另外我给自己定了一条规矩生产环境日志相关的会话用完就ctx flush --session id直接把会话记录删除不让珍贵数据在磁盘上过夜。5.2 上下文腐蚀错误结论会像滚雪球一样放大第二个坑非常反直觉。我原以为上下文越多越准但实际发现上下文里的信息一旦错了后续所有基于它的操作都会错而且错误会被放大。举个真实例子。我有一次做代码批量替换第一轮让工具分析调用方式时它把“带日志参数”的调用错分类成了“纯格式化”的调用。这个错误结论记进了上下文。后面所有依赖这个分类的替换操作都基于错误的分类进行导致工具自信地改错了 7 处。发现问题时前面的错误结论已经成了“既定事实”工具在自己的上下文里反复引用它。解决方案是引入“检查点”机制。我在上下文里增加了 CHECKPOINT 语义每个阶段性结论都要显式标记为“已确认”或“待核实”。对于待核实的结论我在 prompt 模板里明确要求工具不能作为后续操作的依据。实测效果很好误分类这类错误在传播前就被拦下来了。5.3 并行会话的竞态与串号第三个坑出现在我同时开多个终端窗口时。两个终端在同一个目录下跑同一个任务它们共享一个 session 文件写操作互相覆盖。最糟糕的一次A 窗口查的结果被 B 窗口的记录冲掉了导致 A 窗口后续查询用了半份上下文。我的处理有两层。第一层加锁。写 session 之前先抢一个文件锁防止并发写入。第二层每个终端窗口默认使用独立的 session 别名。实现方式是在会话名字后面拼上一个终端标识符这样即使目录相同不同窗口也不会写同一个 session。代价是上下文不共享但本来就该隔离——两个窗口处理的任务通常不一样强行共享反而会串。5.4 上下文太长导致命令直接失败第四个坑是工程层面的。前面提到的argument list too long不是玩笑。上下文拼接后如果超过 2MBLinux 命令行参数直接被内核拒绝。解决办法有三个优先用环境变量注入超长时按关键词做裁剪对管道注入的场景先ctx truncate --session id压缩历史再构建。我后来给 build_context 函数加了自动压缩逻辑。检测到字符数超过上限时会把 8 轮窗口缩到 4 轮把摘要从一句话改成一个关键词列表。大部分情况下压缩到 4 轮近期内容加上摘要就足够让工具保持足够的上下文感知了。5.5 目录切换带来的“跨界”误操作最后一个是开头那类问题的余波我虽然引入了目录比对但第一次实现时只比对不拦截。有一次切换目录后继续跑命令工具提示“当前目录与会话记录的目录不一致”但只是打印了一行警告命令照样执行了。结果还是改错了地方。我后来改成了强制策略不一致时直接中止必须显式确认或者ctx switch --session id --workspace path重新绑定目录才允许继续。实测这个“硬拦截”很有必要比任何温和提示都有效。整理这篇记录时我又翻了一遍最初那个时间戳事故的命令行历史。现在同样的场景工具会在我执行前拦住我问一句你当前在测试环境目录但会话上下文里记录的是生产归档确认要继续吗就是这么一句拦截避免了当时那一整轮的返工。我自己实际的体会是context-mode 的价值不在于把历史原样存下来而在于让工具形成一种“我知道我们在做什么”的判断力。它会提醒你上下文里的矛盾会阻止跨目录的误操作会在你做批量操作前把分类结果亮出来给你确认。这些能力都是单纯无状态命令无法提供的。如果你也在维护命令行工具我建议从这个模式入手改造——它带来的体验提升是立竿见影的而成本不过一百行代码。