有段时间我在重构一个内部用的命令行工具代码里到处是if interactive:、if debug:、if use_cache:这类判断刚开始觉得没啥等模式多了以后这些标志位互相纠缠改一处崩三处。后来我把整个工具的交互状态收敛成一个独立模块代号就叫context-mode——它做的事情很简单把当前程序运行在什么上下文里作为一个显式的、可切换、可保存、可回滚的状态来管理。这篇文章会把我在做这个模块时的完整思路、数据结构、踩坑记录和适用范围都摊开来讲。如果你也在做 CLI 工具、AI 代理、编辑器插件或者任何同一套逻辑在不同场景下有不同表现的系统这篇文章应该对你有用。下面直接进入正题。1. 为什么我会在一个小工具里折腾出独立的 context-mode1.1 最初的麻烦状态判断散落在每个函数里我先描述一下最原始的问题长什么样。假设你有一个命令行工具它有两种运行场景一种是人坐在终端前敲命令另一种是 CI 脚本里非交互调用。两种场景下参数来源不一样、输出格式不一样、错误处理也不一样。大多数人会怎么写在函数开头加判断def load_input(): if sys.stdin.isatty(): return prompt_user() else: return read_env()这个写法在函数少的时候没问题。但等你有十几个函数都需要知道当前是交互模式还是脚本模式每个函数都来一遍if问题就来了你根本不知道改动一个分支会影响哪些调用路径。更麻烦的是这些判断往往不止一层interactive和debug经常组合出现四五个布尔标志位的排列组合足以让代码变成一团乱麻。我当时的情况就是这样。工具里有几个标志位互相之间的依赖关系靠大脑记忆结果就是每次发布前都要花大量时间做回归测试因为总有几个组合没覆盖到。1.2 配置开关和上下文模式的本质区别在动手写context-mode之前我首先想清楚了一个概念问题我要做的到底是一堆配置开关的管理器还是一个上下文模式的运行时这两者看起来像但本质上有区别维度传统配置开关context-mode数据来源读取固定的配置文件或环境变量由当前运行场景动态决定生命周期进程启动后基本不变随会话、请求或嵌套块变化修改方式改配置、重启进程运行中切换、进入、退出影响范围全局一致可以局部生效退出即还原典型问题改一个全局开关影响所有逻辑切换时状态同步和清理是难点一句话总结配置开关回答的是我告诉你现在是什么上下文模式回答的是当前环境决定了现在是什么而且这个状态随时可能变。举个例子手机上的会议模式和日常模式底层的通讯录、消息记录都在但铃声、通知策略、主屏布局完全不一样。context-mode要做的就是给程序也装上这么一套模式切换的能力而不是在每个功能里手动判断一遍我现在该不该响铃。1.3 什么情况下你的项目才需要它也不是所有项目都需要上context-mode。根据我的经验出现下面三个信号中的至少两个才值得认真考虑同一个行为在不同场景下表现差异明显比如交互模式下要人肉输入脚本模式下要读环境变量调试模式下要输出堆栈。场景之间切换频繁而且各自需要保留状态这里说的状态可能是缓存、历史记录、临时文件路径。团队里已经有人在传标志位比如 A 函数设了个全局变量B 函数靠读它来改变行为。这就是context-mode的雏形但它没有边界、没有生命周期管理早晚出事。我当时三个信号全占了所以才下决心把这套东西从业务代码里抽出来。2. 先分清模式和上下文设计前必须做的需求拆解2.1 动手之前先问三个问题很多人做这类模块上来就写一个ContextManager类类里有set(key, value)和get(key)然后就结束了。这样写出来的东西本质上是个升级版字典并不能解决模式切换这个核心问题。我在动手前先逼自己回答了三个问题我的系统里到底有哪几种模式模式之间靠什么条件切换切换时哪些状态需要保留哪些可以丢弃以那个 CLI 工具为例我整理出的模式表是模式触发条件典型行为interactive标准输入是 TTY且没有--headless参数从 stdin 读参数、输出有颜色、错误提示友好script标准输入不是 TTY或显式传了--script从环境变量读参数、输出纯文本、出错直接抛异常debug环境变量DEBUG1打开堆栈、写详细日志、不清理临时文件三个问题一答完模式的概念就清楚了不再是一堆if组合而是一组互斥且有明确边界的运行态。2.2 把状态拆成三层静态配置、运行时状态、持久化上下文模式定义好之后下一步是理清哪些数据跟着模式走哪些数据不跟。我把所有数据分成三层这个分类是整个设计最关键的决策层级内容随模式切换例子静态配置不随模式变化的基础信息否工具名、版本号、默认超时时间运行时状态模式特有的当前状态是当前数据源、输出目标、日志级别持久化上下文跨模式共享的用户数据部分命令历史、缓存目录、上次选择的项目为什么要分这么清楚因为如果所有东西都塞进同一个Context对象切换模式时就会面临要不要清空的两难清了历史记录丢了不清上一模式的数据污染下一模式。我当时遇到过一个具体例子工具在interactive模式下会让用户选一个项目选完记住它下次进interactive时默认选中同一个项目。这个上次选择的项目就属于持久化上下文不应该因为切到script模式就被清掉。但如果把当前正在分析哪个项目这种运行时状态也当成持久化上下文那script模式就会拿上一次交互会话的状态去跑批处理结果完全不可控。2.3 一个具体的设计结果ModeKind、ContextScope、Store把概念拆完最终的context-mode模块结构是这么定的三个核心概念ModeKind模式枚举。一个应用的模式集合是有限的用枚举比用字符串更安全。ContextScope某个模式下可见的上下文数据。换一种说法它是一个命名的数据槽只属于特定模式。Store管理当前模式、模式栈以及所有 Scope 的容器。这是模块的门面也是唯一对外暴露的入口。这种设计的好处是每个模式有自己独立的ContextScope模式切换时只需要更换当前的 Scope 指针不需要清空或迁移任何数据。什么时候该保留、什么时候该丢弃在进入模式时用复制默认值 选择性继承的策略来处理。3. 最小可用实现一个能跑起来的 ContextManager3.1 数据结构定义我不喜欢把一个简单的模块写得像企业级框架所以context-mode的核心实现非常朴素。下面是我用 Python 写的一个精简版去掉业务逻辑后大概不到一百行。from enum import Enum from typing import Any, Dict, Optional class ModeKind(Enum): INTERACTIVE interactive SCRIPT script DEBUG debug class ContextScope: 单个模式下的上下文数据槽 def __init__(self, mode: ModeKind, defaults: Optional[Dict[str, Any]] None): self.mode mode self._data dict(defaults or {}) def get(self, key: str, default: Any None) - Any: return self._data.get(key, default) def set(self, key: str, value: Any) - None: self._data[key] value def update(self, mapping: Dict[str, Any]) - None: self._data.update(mapping) class ContextManager: context-mode 的核心管理当前模式和模式栈 def __init__(self, initial_mode: ModeKind ModeKind.SCRIPT): self._scopes: Dict[ModeKind, ContextScope] {} self._stack: list[ModeKind] [initial_mode] self._current: ModeKind initial_mode self._ensure_scope(initial_mode) def _ensure_scope(self, mode: ModeKind) - ContextScope: if mode not in self._scopes: self._scopes[mode] ContextScope(mode) return self._scopes[mode] property def mode(self) - ModeKind: return self._current def scope(self, mode: Optional[ModeKind] None) - ContextScope: 获取某个模式的上下文默认返回当前模式 target mode or self._current return self._ensure_scope(target) def switch(self, mode: ModeKind) - None: 显式切换当前模式不改变栈深度 self._current mode self._ensure_scope(mode) def enter(self, mode: ModeKind) - None: 进入子模式压栈 self._stack.append(mode) self._current mode self._ensure_scope(mode) def exit(self) - ModeKind: 退出子模式弹栈返回被还原到的模式 if len(self._stack) 1: raise RuntimeError(cannot exit root mode) self._stack.pop() self._current self._stack[-1] return self._current这里我刻意没有做任何数据迁移的魔法因为魔法最容易出 bug。每个模式一个独立的ContextScope互不干扰切换成本几乎为零。3.2 核心 APIenter / switch / exit 的语义差异这个实现里有三个容易混淆的方法switch、enter和exit。它们的语义必须非常清楚switch是平级切换。比如从interactive切到script当前模式变了但不会有进入后再退回来的概念。enter是嵌套进入。适合临时进入一个模式干点事干完退出还原的场景。比如在interactive模式下用户执行了一条需要debug模式的命令用enter进入debug命令执行完再用exit弹回来。exit只能弹出enter压进去的栈。根模式不能被退出否则整个系统就没有兜底了。Stack 的设计解决了我最早的一个痛模式是可以嵌套的。没有栈的话进入 debug 再出来你就不知道出来应该回到interactive还是script。有了栈这个问题天然解决。3.3 切换时数据的迁移规则真正需要做数据迁移的场景是进入一个模式时这个模式需要看到父模式的某些状态。比如debug模式需要知道当前处理的项目路径这个路径是interactive模式里用户选的。我采用的策略是进入时从父模式复制快照之后完全独立。看代码def enter_with( self, mode: ModeKind, inherit_keys: Optional[list[str]] None, ) - None: 进入子模式并从当前模式复制指定 key 的值 parent_mode self._current self.enter(mode) scope self.scope(mode) if inherit_keys: parent_scope self.scope(parent_mode) for key in inherit_keys: if parent_scope.get(key) is not None: scope.set(key, parent_scope.get(key))注意这个复制是值拷贝还是引用拷贝取决于你的数据形态。像项目路径这种不可变字符串直接复制没问题。如果是可变对象建议复制一份新的否则子模式里改了父模式也会被影响。为什么不直接让子模式实时读父模式因为那样两个模式就耦合了。子模式可能会修改数据改完不知道要不要写回父模式写回又会产生脏数据问题。快照复制虽然有一点内存开销但语义最清晰排查问题也容易。3.4 一个真正能用的小例子CLI 入口把上面的类串起来就是这样一个实际调用场景def build_context() - ContextManager: ctx ContextManager() if os.environ.get(DEBUG): ctx.switch(ModeKind.DEBUG) if not sys.stdin.isatty() or --script in sys.argv: ctx.switch(ModeKind.SCRIPT) else: ctx.switch(ModeKind.INTERACTIVE) ctx.scope().update({ data_source: read_data_source_by_mode(ctx.mode), output_format: json if --json in sys.argv else plain, }) return ctx def main() - None: ctx build_context() if ctx.mode ModeKind.INTERACTIVE: selected prompt_user(select project: ) ctx.scope().set(project, selected) else: ctx.scope().set(project, os.environ.get(PROJECT)) run_tool(ctx) if __name__ __main__: main()看到区别了吗入口处算好模式之后所有函数不再需要if sys.stdin.isatty()这种判断只需要ctx.mode和ctx.scope()。逻辑收拢到一处排查问题时只需要看入口的build_context。4. 模式切换时最容易踩的坑排查笔记光有设计和代码是不够的真正让这套东西稳定靠的是在坑里滚过一圈。下面这四个坑是我的真实排查记录每一个最后都导致了代码结构的调整。4.1 坑一提前清理旧上下文想切回来却找不到数据第一次实现时我在exit()里顺手把当前模式的所有数据清空了理由是退出模式后这些数据没用了。结果很快被打脸。具体现象是用户从interactive模式enter到debug模式调试完exit回到interactive发现刚才输入的项目名、上次的搜索结果全没了。排查过程很直接我给exit()加了一行日志发现旧 scope 被整个clear()了而交互模式最需要的就是会话状态。结论退出模式不等于销毁模式。模式栈弹出的是光标位置Scope 里的数据应该保留直到进程结束或显式清理。这也是我现在把ContextScope的set/update和clear完全分开的原因。如果你真的需要重置某个模式的状态单独提供一个reset_scope方法就好了。4.2 坑二全局共享单例把 A 会话的数据带到 B 会话ContextManager如果做成模块级单例两个并发的会话会互相污染。我最初确实踩了这个坑而且做的是最糟糕的单例写法——一个全局实例所有会话共享同一个_current和_stack。排查时是一个用户报的 bug开两个终端同时跑同一个工具A 终端切到了debug模式B 终端里原本是interactive模式的命令突然也带上了调试输出。看代码立刻明白了两个终端跑的是同一个进程里的同一个单例模式状态完全共享。解决思路是单例只能用来保存默认配置真正的运行上下文必须按会话隔离。我给管理器引入了session_id的概念每个会话拥有独立的上下文栈单例里只存session_id - ContextManager的映射关系。实际代码我用了contextvars.ContextVar这个后面坑三会提到它天然支持异步场景的隔离。4.3 坑三异常路径下模式没有还原enter和exit成对使用最怕中间抛异常。比如你在interactive模式下进入debug去执行一段可能抛错的代码debug模式进了exit没执行整个工具从此停留在debug模式之后所有操作都变成调试模式而且排查难度极大——因为当前模式错了这个错误本身不会出现在任何日志里。排查时我发现异常导致的模式泄漏在同步代码里还能靠 traceback 勉强定位一旦涉及异步任务问题会变得非常隐蔽。修复方式很简单用contextlib.contextmanager把enter/exit包成可自动还原的上下文管理器。from contextlib import contextmanager contextmanager def mode(ctx: ContextManager, target: ModeKind, inherit_keysNone): previous ctx.mode if inherit_keys: ctx.enter_with(target, inherit_keys) else: ctx.enter(target) try: yield finally: while ctx.mode ! previous: ctx.exit()注意finally里的while循环如果目标模式内部又嵌套了别的模式一个exit可能退不到原来的层级循环退栈才是最稳妥的做法。4.4 坑四异步并发时上下文丢失ContextVar 是关键这是整套context-mode设计里最核心的坑也是我把模块大改一次的导火索。事情是这样的工具后来加了一个异步接口用asyncio并发处理多个请求。我在测试时发现两个并发请求的模式状态串了请求 A 是interactive请求 B 是script但 B 在运行中经常会读到 A 的模式信息。更诡异的是加日志打印self._current看起来又是对的只是scope()返回的数据不对。最后定位到根本原因全局变量在协程之间是共享的。我的ContextManager._current和_stack是实例属性没错但如果这个实例被多个协程共用那它们看到的还是同一个状态根本做不到并发隔离。正确解法是用contextvars.ContextVar它的设计初衷就是解决异步任务中的上下文隔离问题import contextvars # 每个协程/任务可以拥有独立的 context _current_mode_var: contextvars.ContextVar[ModeKind] contextvars.ContextVar( current_mode, defaultModeKind.SCRIPT ) _scope_var: contextvars.ContextVar[dict] contextvars.ContextVar( current_scope, default{} ) def current() - ModeKind: return _current_mode_var.get() def set_mode(mode: ModeKind) - None: _current_mode_var.set(mode)这样做以后每个异步任务各自持有自己的模式和 Scope互不干扰。如果你在写 FastAPI、Tornado 或任何基于 asyncio 的服务这个坑几乎一定会遇到记住了上下文跨异步任务传递不要用普通类属性直接用contextvars或者框架自带的ContextVar/AsyncLocal机制。4.5 我的自检清单经历过上面几个坑之后我在代码审查时都会照着这份清单检查每个模式入口是否用with或contextmanager保护保证异常时也能还原模式exit是否只弹栈、不销毁数据会话级上下文是否用session_id或ContextVar做了隔离新加的模式是否在入口处统一决定而不是散落各处逐步switch根模式是否不可退出5. 什么时候别上 context-mode以及它还能用在哪些场景5.1 不要过度设计三个不需要信号我虽然花了不少篇幅讲context-mode怎么设计和实现但我也清楚它不是银弹。下面三种情况我更建议保持简单第一系统里只有两种模式且从不嵌套比如有网/没网这种情况一个布尔变量配合if判断足够了不需要管理器和模式栈。第二各模式之间的状态交集很小互相不影响。比如output_format是 JSON 还是 YAML这一个键值就搞定了引入完整的上下文模式反而增加心智负担。第三切换频率极低且没有回退需求。比如初始化模式和运行模式俩模式只在启动时切换一次之后不会再切回来。这种用普通流程控制就能解决不需要把它建构成一个运行时系统。context-mode解决的痛点始终是频繁切换、可嵌套、需要保留各自状态。如果你没有这个痛点那简单就是最好的设计。5.2 扩展方向AI 代理、编辑器、链路追踪我把这套模块从 CLI 工具里抽出来以后发现它的抽象粒度其实非常适合另外几类场景场景模式怎么定义上下文中放什么AI 代理/对话系统不同人格、不同记忆策略、不同工具启停系统提示、记忆片段、当前任务目标编辑器/IDE 插件不同语言环境、不同项目类型、不同调试会话补全源、格式化风格、调试断点集可观测性/日志系统不同请求、不同用户会话trace_id、用户 ID、日志级别、过滤条件批量任务处理交互模式、批处理模式、审核模式输入源、输出路径、审批策略拿 AI 代理来说一个代理可能在规划模式、执行模式和审核模式之间切换每个模式需要不同的提示词模板、工具权限和上下文记忆。用context-mode管理这些比全局变量和散落的判断要可靠得多。5.3 最后再分享一个实践技巧如果看完了前面的内容你想在自己的项目里试着做一版context-mode我最后的建议是先把模式和状态的清单写出来再写代码。别急着定义类和方法先拿一张纸或者一个 MD 文档写下你的模式表、切换条件、保留数据列表。你会发现很多设计缺陷在写文档阶段就能暴露出来根本不用等上线。我在重构那个 CLI 工具时这个先画表再编码的习惯帮我省了至少两轮返工。现在context-mode类的代码稳定运行了很长时间几乎没有再改过的需求。把模式从业务逻辑里抽出来之后新增一个模式只需要加一行枚举、写几行入口判断、加一个 Scope业务代码一行都不用动。