
最近整理代码时我发现一个很典型的工程现象context-mode这个词几乎每过一段时间就会换一副面孔出现在面前。最开始是在命令行工具里碰到某个子命令在不同项目目录下行为完全不同调试到怀疑人生后来自己写解析器同一段字符流因为前面出现的标记不同会被解释出完全不同的含义再后来给大模型应用做上下文管理发现上下文窗口根本不是越大越好反而需要人为切分出各种 mode 来控制哪些内容能进、哪些不能进。这三个看似不相干的场景底层其实是同一个问题一段数据到底应该被怎样解释取决于它当前处在什么模式之下。这篇文章就把我自己实现和调优 context-mode 的过程完整拆开讲一遍。从最基础的问题定义、状态机实现、嵌套模式栈到 AI 场景里的上下文预算管理再到我踩过的一堆坑都会覆盖到。适合正在写解析器、CLI 工具、多状态业务系统或者做大模型应用上下文管理的开发同学参考看完可以直接拿里面的思路和代码去改造自己的项目。1. 先理解问题context-mode 解决的是解释权问题1.1 一个输入、多种语义在正式开始写代码前我觉得有必要把 context-mode 的本质讲透。它不只是一个开关变量而是程序在面对同一份输入或同一段数据时要依据当前所处场景去选择不同的解释规则。举个最直观的例子在 C 语言预处理阶段#开头的是指令但在普通代码行里#可能是注释的一部分。编译器在读到#时必须知道自己当前处于预处理指令上下文还是普通代码上下文才能决定接下来的字符流该怎么切分。这就是最经典的 context mode只是很少有人把它单拎出来说。换到业务系统里也一样。同一个list命令在用户登录前执行应该返回请先登录在某项目已被激活时执行应该列出该项目下的文件在 debug 模式开启时执行可能还要附带打印内部状态。命令文本一字不差语义却完全不同。这种情况下你没法靠命令本身拿到正确结果必须靠外部引入的当前模式来决定行为。1.2 context 污染的低级事故和 context-mode 相对的概念是 context 污染。什么叫污染就是该隔离的上下文信息被错误地共享了。我早期在做一个内部管理后台的时候因为偷懒用全局变量存当前用户的操作模式结果出现了一个非常诡异的线上事故A 用户开启了批量编辑模式B 用户的请求进来后代码去读全局状态也误以为自己在批量编辑模式把还没编辑完的数据直接提交了。当时排查了很久最后发现就是一行全局 flag 导致的跨用户状态泄漏。这个事故让我对 context-mode 的写法有了敬畏模式不是玄学它是一段有边界、有生命周期、必须被正确创建和销毁的状态。1.3 你需要 context-mode 的三个信号结合这些年的经验我总结出三个信号出现任意一个基本就说明你的系统需要认真设计 context-mode 了信号的解释规则不止一套。同一个输入/用户指令在不同前置条件下要走进完全不同的处理分支。状态变量散落各处。你不得不在十几个函数签名里传递mode、state、stage这类参数传漏一个就是 bug。测试用例数量失控。每加一个功能就要在所有历史模式下重复验证一遍老逻辑组合数量呈指数级膨胀。这三个信号不是等它自然爆发才处理而是越早识别越好。状态 flag 从五个变成十五个的时候再重构的成本会高得让你不想动手。2. 从零搭一个带 context-mode 的命令处理器2.1 需求定义一个多模式的命令行交互工具纸上谈兵没意思我直接用一个真实项目的需求来演示。假设我们要做一个内部部署工具的命令行交互模块它需要支持以下能力/login登录登录后才能执行/deploy这类敏感操作。config进入配置模式配置模式下支持set key value和get key。debug on/debug off开启和关闭调试模式。project use name切换当前项目切换后list的含义跟着变。exit从任何子模式退回父模式。需求看着不复杂但如果你直接用布尔变量硬编码写出来的东西一定会随着需求迭代迅速腐烂。下面我展示两种写法第一种是我最初踩坑的版本第二种才是真正可维护的状态机版本。2.2 第一版处处 if 的全局 flag 大杂烩第一版代码很符合人类直觉但也是灾难的根源# v1全局 flag 堆砌越加越乱 logged_in False debug_on False in_config False current_project default def handle(line: str): global logged_in, debug_on, in_config, current_project if line.startswith(/login): if not logged_in: logged_in True print(登录成功) elif line.startswith(/logout): logged_in False elif in_config: if line.startswith(set): parts line.split() print(f设置配置项 {parts[1]} {parts[2]}) elif line.startswith(get): print(读取配置项) else: print(f[config] 未知命令: {line}) elif line.startswith(config): in_config True elif line.startswith(debug on): debug_on True elif line.startswith(debug off): debug_on False elif line.startswith(project use): current_project line.split()[-1] elif line.startswith(list): if debug_on: print(f[debug] 当前项目 {current_project}列出文件) else: print(f列出项目 {current_project} 的文件) elif line.startswith(/deploy): if not logged_in: print(请先登录) else: print(开始部署) else: print(f未知命令: {line})这版的缺点非常明显所有模式状态平铺在一个全局命名空间里随着模式增多if/elif的嵌套深度和判断顺序耦合会越来越难读。比如in_config的分支在config分支之前而debug_on又要在list分支里额外判断一次。你没法从这里看出状态机的轮廓只能看到一团纠缠的字符串匹配。2.3 重构有限状态机才是正路有限状态机的核心思想是把当前模式声明成唯一状态变量每个模式维护自己的一张转移表只有匹配当前模式的命令才会被处理。这样任何输入只会落在一个明确分支里不会出现多个 flag 交叉判断的情况。# v2有限状态机模式即状态 STATES (root, config, debug) TRANSITIONS { root: { /login: None, # 登录成功仍留在 root config: config, # 进入 config 模式 debug on: debug, # 进入 debug 模式 debug off: None, # root 下关闭无效保持 root list: None, project use: None, /deploy: None, }, config: { set: None, # 留在 config get: None, # 留在 config exit: root, # 退出到 root }, debug: { debug off: root, # 退出 debug list: None, exit: root, }, } current_state root logged_in False current_project default def handle(line: str): global current_state keyword line.split()[0] if line.split() else target TRANSITIONS[current_state].get(keyword) if target is None and keyword not in TRANSITIONS[current_state]: print(f[{current_state}] 未知命令: {line}) return if target is not None: current_state target dispatch_keyword(keyword, line) def dispatch_keyword(keyword: str, line: str): # 只处理当前状态相关的业务逻辑 if keyword in (/login, /deploy): # 权限逻辑仍按当前状态判断 pass elif keyword list: if current_state debug: print(f[debug] 当前项目 {current_project}列出目录结构) else: print(f列出项目 {current_project} 的文件)这个重构看起来只是把if/elif换成了字典查询但收益是本质性的状态转移不再散落在条件分支里而是集中在TRANSITIONS这张表里。新增一个模式时你只需要在STATES里加名字、在TRANSITIONS里加一行入口然后单独写该模式的 handler不会污染其他模式的分支。3. 模式可以嵌套从扁平状态机到上下文栈3.1 状态爆炸的现实扁平状态机解决了多模式互相干扰的问题但很快会撞上下一堵墙模式嵌套。在我们的部署工具里用户可能会进入config模式后再输入edit进入配置编辑子模式编辑子模式里还有一个session子模式用来保存多步修改。如果坚持用单一状态变量记录模式config、config/edit、config/edit/session三个层级各是一个独立状态将来再加一个新子模式所有组合都要在转移表里补齐状态数量瞬间爆炸。这也是很多开发者从状态机转向上下文栈的原因嵌套是一种天然的栈结构用栈来实现每个层级就是一次入栈退出就是一次出栈不需要为所有组合提前枚举状态。3.2 用上下文栈管理嵌套模式我采用的方案是维护一个ContextStack栈顶永远对应当前模式。进入子模式就 push 一个帧退出就 pop 一个帧。每一帧除了模式名还可以携带该模式自己的局部数据这样不同层级的数据天然隔离不会互相污染。# v3栈式上下文管理器 class ContextFrame: 一次模式切换就是一个上下文帧 def __init__(self, name: str, handlerNone, data: dict | None None): self.name name self.handler handler self.data data or {} class ContextStack: def __init__(self): self._stack [ContextFrame(root, handlerroot_handler)] property def current(self) - ContextFrame: return self._stack[-1] def enter(self, frame: ContextFrame) - ContextFrame: self._stack.append(frame) return frame def exit(self) - ContextFrame: if len(self._stack) 1: raise RuntimeError(root 模式不允许退出) return self._stack.pop() def run(self, line: str): frame self.current if frame.handler: return frame.handler(line, self) return f[{frame.name}] 该模式尚未注册 handler def config_handler(line: str, stack: ContextStack): if line.startswith(edit): stack.enter(ContextFrame(config/edit, handleredit_handler)) return 进入配置编辑模式 if line.startswith(get): return f读取配置项 if line exit: stack.exit() return 退出配置模式 return f[config] 未知命令: {line}这种设计的最大好处是每个模式的 handler 都是独立函数它可以通过stack.enter主动拉入子模式也可以用stack.exit把自己弹出。ContextStack负责生命周期handler 只关心自己的业务和需要触发的转移。再复杂的层级关系也只是一条不断入栈出栈的动态路径而不是一张摊平的组合表。3.3 把模式变成可插拔组件如果项目规模再大一点所有 handler 集中在同一个模块里会让文件膨胀到几千行。这时候我会引入一个模式注册表用装饰器把每个模式定义成独立类或独立函数注册进去之后按名字创建。这个思路和 Django/Flask 的 url 注册、FastAPI 的 router 都非常像核心就是让模式变成可枚举、可插拔的组件。# v4模式注册表 MODES {} def mode(name: str): def decorator(cls): MODES[name] cls return cls return decorator class BaseMode: def on_enter(self, stack: ContextStack): pass def handle(self, line: str, stack: ContextStack): raise NotImplementedError def on_exit(self, stack: ContextStack): pass mode(config) class ConfigMode(BaseMode): def handle(self, line: str, stack: ContextStack): if line.startswith(edit): stack.enter(MODES[config/edit]()) return 进入编辑模式 if line exit: stack.exit() return 已退出 return f[config] 未知命令: {line} mode(config/edit) class ConfigEditMode(BaseMode): def handle(self, line: str, stack: ContextStack): return f[config/edit] 编辑指令: {line}注册表配合 BaseMode 的生命周期钩子on_enter、handle、on_exit让我可以在模式切换时统一做资源初始化与清理。例如进入config模式时加载配置文件退出时自动写回磁盘。这个能力在扁平状态机里需要手动处理在模式注册表里只需在基类钩子里写一次所有模式自动复用。4. 同一个坑在大模型应用里叫 context 管理4.1 上下文窗口和有效上下文的距离说完了传统软件里的 context-mode我想把视野拉到大模型应用上。很多人觉得大模型的 context-mode 就是把历史消息全塞进 prompt真的这么简单就不会有那么多团队为上下文管理掉头发了。先纠正一个概念上下文窗口看着很大但有效上下文远没有那么乐观。研究里反复验证过一个现象叫lost in the middle模型对长文本中间部分的信息利用能力明显低于开头和结尾。也就是说即便你给了模型 128k token 的内容它真正能稳定利用的往往只有前后各一小部分。这种情况下上下文量大没有意义结构清晰、突出重要信息才有意义。4.2 最差的 context 拼接方式我见过最糟糕的 context 拼接代码大概长这样def build_prompt(user_input: str): history get_all_chat_history() # 全部历史 docs search_knowledge_base(user_input) # 十个检索结果全部贴上 tool_output call_weather_api(user_input) # 可能用不上的工具结果 return f 系统提示词: ... {history} {docs} {tool_output} 用户输入: {user_input} 这段代码在 demo 阶段看不出问题上线后就开始花式犯错历史记忆覆盖了当前任务重点无关知识片段让模型答非所问工具返回占用了大量 token 却根本没被引用。之所以说它糟糕是因为它把所有上下文一视同仁堆在一起没有任何层级和优先级。而 context-mode 的思想恰恰是内容不是越多越好关键是当前这个请求属于什么模式该模式只需要哪些上下文。4.3 把 context-mode 的思想搬进 token 预算管理我在自己的 LLM 应用里把上下文管理做成了类似前面 ContextStack 的机制。先按内容角色切出固定模式再给每个模式分配独立的 token 预算上下文模式内容来源预算以 128k 模型为例注入策略system系统提示词、工具定义4k每轮固定注入memory用户长期偏好、画像摘要2k低频更新会话内固定history最近对话记录24k滑动窗口保留最近 N 轮scratchpad当前任务中间结果4k短生命周期任务结束即清空knowledge检索到的外部知识片段32k按请求主题按需注入tool_result工具调用返回结果8k只保留最近一次结果output模型最大输出8k必须预留不能挤占overheadtokenizer 误差、格式开销2k保底加起来是 84k离 128k 上限还有 44k 缓冲区足够应付突发检索或长工具返回。关键的代码逻辑是在注入任何模式前先估算已用 token如果超预算就触发压缩策略而不是无脑全塞。class LLMContextManager: def __init__(self, token_limit: int 128_000, output_reserve: int 8_000): self.token_limit token_limit self.output_reserve output_reserve 2_000 # 预留输出和 overhead self.available token_limit - self.output_reserve self.modes {} # mode - 内容块列表 def enter_mode(self, mode: str, blocks: list[str]) - list[str]: needed sum(estimate_tokens(b) for b in blocks) used self._used_tokens() if used needed self.available: return self._compact(mode, blocks) # 触发压缩/淘汰 self.modes[mode] blocks return blocks def _used_tokens(self) - int: return sum(estimate_tokens(t) for blocks in self.modes.values() for t in blocks) def _compact(self, mode: str, blocks: list[str]): # 优先压缩 history其次压缩 knowledge最后丢弃 tool_result for target in (history, knowledge, tool_result): if target in self.modes: self.modes[target] summarize(self.modes[target]) if self._used_tokens() sum(estimate_tokens(b) for b in blocks) self.available: break return blocks每次请求进来时先根据意图判断本请求属于问答模式工具调用模式还是持续创作模式再决定把哪些上下文块注入 prompt。效果比之前的全量拼接稳定很多可解释性也更强如果模型答错了我能直接查当时注入了哪些块的上下文而不是在一个几千行的 prompt 里大海捞针。5. 调试实录五个我踩得最深也最容易被忽略的坑5.1 状态泄漏一个人开着的模式另一个人被带偏这是我在文章开头提到的线上事故的完整版描述。症状是用户 A 开启了 config 模式用户 B 的请求进来后系统错误地对 B 执行了 config 模式下的命令。根因是我把 context 状态放到了类变量或者模块级别导致所有请求共享同一个栈。修复方式非常简单但必须坚持每个用户会话、每个请求都要创建独立的 ContextStack 实例。千万别为了省那一次对象创建就把栈提到公共层级。尤其是 Web 应用里Python 的类变量、Go 的包级变量、Java 的静态字段这些位置放 context 基本等于埋雷。5.2 模式切换时机没对齐半个 token 被吃掉了在写解析器时我遇到过一种特别隐蔽的问题模式切换发生在词法位置的中间导致前半个 token 按旧模式解析、后半个 token 按新模式解析出现了一个既不属于旧语义也不属于新语义的畸形结果。比如输入是config set/theme解析器在读完config set后切换到 set 子模式但此时剩下的/theme因为中间多了一个斜杠与任何模式的语法都不匹配。更隐蔽的是如果切换逻辑在字符级别的循环里你根本说不清到底是哪一步吞掉了字符。对策是明确的模式切换一定要卡在语法安全的边界通常是完整 token 或完整行的末尾。如果业务非要支持中途切换那就必须先把当前 token 消费完整再执行状态转移绝不能在 token 中间做上下文切换。5.3 异常路径没有恢复现场栈式 context 最大的优点是入栈出栈操作简单最大的风险是忘了恢复现场。如果某个子模式的 handler 中途抛了异常而你没有在finally或上下文管理器的__exit__里执行出栈那整个 ContextStack 就会永久停留在一个半死的模式里所有后续请求全部被带偏。所以我强烈建议所有进入子模式的操作都通过with stack.enter(...)这种上下文管理器语法来完成而不是裸调用enter和exit。这样即便中间抛异常栈也会被正确弹回。我在 Python 里习惯自定义一个上下文管理器包住 push/pop在 Go 里则是用 defer 来保证弹出。5.4 上下文栈的弹栈顺序错了栈式结构要求严格的 LIFO。有一回我写了一个需求从任意子模式执行exit时希望直接跳回 root。我图省事写了循环弹栈直到栈底为止。结果另一个功能依赖从 config/edit 退到 config的逻辑发现中间层级被全部丢掉了。后来我把退出语义定义清楚区分为三种弹出当前一层、弹出到指定模式、弹出到 root。三种只有第一种是基础操作后两种都必须基于第一种组合实现而且要在日志里打出来避免误判。不要把弹栈顺序做成全局约定时间一长你自己都会忘。5.5 日志没有上下文标识出问题只能靠猜最后也是一个工程素养问题。context-mode 这种机制天然是状态相关的一旦出问题你第一件事不是看报错信息而是看当时处于什么模式、模式是怎么转移过来的。如果日志里没有记录这些信息排查成本会翻好几倍。我现在要求所有 handler 的日志里至少包含三要素请求/会话 ID、当前模式名、入参摘要。线上出现异常时直接按会话 ID 拉出模式转移链路分分钟定位是哪个转移条件写错了。这个习惯花不了多少时间但能把平均排查时间从小时级降到分钟级。6. 最后一点什么时候别用 context-mode聊了这么多 context-mode 的用法我想在最后补一个反向经验别为了用而用。如果你的业务逻辑里只有两个模式且嵌套不超过一层那老老实实用一个布尔变量就是最优雅的方案。强行引入状态机或 ContextStack只会增加阅读成本和维护负担。我自己的判断标准是模式数超过 4 个、转移条件超过 10 条、或者模式开始嵌套两层以上才值得动工做专门的状态管理。否则简单方案永远是第一选择。另一个经验是关于模式命名的。我踩过最深的坑是把模式名跟命令名绑定比如叫config_mode、list_mode后来需求一变命令改名了模式名却改不了满是历史包袱。正确做法是让模式名描述语义解释规则而不是具体命令config、deploy、editing、query这种抽象程度比较合适。因为模式本质上是解释权的一种归类命令只是触发转移的输入两者不该混为一谈。这次把 context-mode 从前端命令行一直讲到后端 AI 应用核心想传递的就一件事任何解释规则随场景变化的地方都需要显式的模式管理。不要依赖一堆飘散的 flag也不要迷信越复杂越好的架构找到适合你的那层抽象把边界和生命周期管理好它就会成为整个系统最稳定的一块地基。这些经验都是我用线上事故和无数个调试夜晚换来的希望读到这里的你能少走点弯路。