context-mode全解析:从并发上下文传递到大模型提示词管理

发布时间:2026/10/29 14:16:13
context-mode全解析:从并发上下文传递到大模型提示词管理

第一次看到“context-mode”这个标题,我脑子里瞬间闪过两种完全不同的画面:一种是 Go 程序里那个被到处传参的 ctx,另一种是跑大模型 Prompt 时反复调整的上下文窗口。后来我才发现,这两条线在底层其实是一回事——凡是要让多个模块协作的系统,都得先回答一个问题:怎么把上一站的上下文,准确、完整、还有成本可控地传递给下一站。这篇文章我就围绕“context-mode”这个关键词,把并发任务里的上下文传递、大模型提示词的上下文组织、以及一个手写极简上下文工具的完整过程拆开讲透。适合正在写后端服务、搞 AI 应用编排,或者被“上下文串了”“超时没生效”这类问题折磨过的开发者参考。

1. 题目拆解:context-mode 到底是什么

1.1 一个词背后的三种常见理解

我在不同的技术讨论区里看过很多人讨论 context-mode,最后基本收敛到三个方向。

第一个方向是并发编程里的 context 模式。以 Go 的context.Context为典型代表,Python 里有contextvars和asyncio的 Task 上下文,Java 的ThreadLocal也算同一类东西。这类模式解决的核心问题是:当程序里有大量并发任务时,怎么把请求级别的信息(超时时间、取消信号、traceId、用户身份)安全地传递到每一个异步分支里,而不是靠到处设置全局变量。

第二个方向是大模型应用里的上下文模式。用 ChatGPT、Claude 这类模型时,你输入的历史消息、系统提示词、检索回来的知识片段,加在一起构成了模型的上下文窗口。怎么组织这些内容、截取哪些、按什么顺序放,市面上管这个叫 prompt context management,英文文档里也经常写成 context-mode。这类模式解决的核心问题是:怎么在有限的上下文窗口里,让模型记住该记住的,忽略不该记住的。

第三个方向是编译原理和语法分析里的上下文相关模式。不过这个更适合编译器开发者,和大多数业务开发者的距离比较远,我们这篇先不展开。你只需要知道,当一个标题只写“context-mode”而不带任何限定词时,大部分一线开发者默认讨论的是前两种。

1.2 为什么这三条线本质上是同一个问题

我为什么说这两种主流理解在底层是一回事?因为它们最终都在处理同一件事:状态的有界传递。

后端服务是一个接力赛。HTTP 请求进来之后,可能拆成好几个协程并行处理,每个协程又去调数据库、调消息队列、调下游服务。如果每一棒都不带上一棒的编号和截止时间,跑着跑着就乱套了:一个请求超时了,它的子任务还在傻傻地跑;一个请求失败了,日志里完全分不清是哪条链路上的哪个环节出了问题。这就是并发 context 要管的事。

大模型应用其实是另一种接力赛。用户说了一句话,程序要先把历史对话、搜索摘要、知识库片段拼成一个上下文快照,然后交给模型推理。模型输出完,程序又把新的消息追加进上下文,准备下一轮调用。如果每一轮都无脑地把所有历史都塞进去,很快上下文窗口就爆了,费用也爆了。这就是大模型 context-mode 要管的事。

两者殊途同归:都是在有限资源约束下,决定哪些信息需要跟着任务走、哪些信息可以丢弃、什么时候该中止任务。想通了这一点,你会发现很多框架的设计都变得特别好理解。我们接下来先聊开发领域更常见的并发上下文传递。

2. 并发与跨模块的上下文传递:迷你世界的快递单

2.1 context 的核心职责:值传递、超时、取消、追踪

用一个生活化类比来理解。假设你是一家餐厅的传菜员,手里的托盘就是 context。托盘上应该放什么?放客人点的菜单(业务数据),放这单必须几点前出餐(deadline),放一个“客人走了就停止做菜”的按钮(cancel),放一个订单号(traceId)。你不可能把整个餐厅的库存表都塞进托盘,你只会放这一单需要的东西。

在代码里,context 对象通常承担四类信息:

  • 值传递:用户ID、语言偏好、租户ID 这类请求级数据。
  • 超时控制:整个请求链路的最终 deadline,子任务共享这个时间余量。
  • 取消信号:父任务失败或超时后,通知所有子任务立即停止。
  • 链路追踪:traceId 或 spanId,用于把日志、监控、异常串联起来。

我用 Python 的asyncio写过一个很典型的场景。一个服务接收到订单请求后,需要同时查库存、查用户余额、查优惠券三个数据源。三个任务并发执行,但它们必须共享同一个超时时间——总超时 500ms,而不是每个任务各自 500ms。如果各查各的,三个任务加起来可能超过 1.5 秒,用户早就等得不耐烦了。这种“共享预算”的语义,用 context 表达最自然。

2.2 跨服务传递时的三件套:traceId、deadline、用户态信息

单机内的 context 传递相对简单,你把 context 对象作为函数参数往后传就行。一旦请求要跨服务调用,问题就变得有意思了。服务 A 要把 context 里的信息通过网络传给服务 B,你不可能直接把对象序列化过去,你只能挑必要的字段塞进 HTTP Header、消息队列的 Properties、或者 gRPC 的 Metadata。

我一般固定传三样东西。第一个是X-Trace-Id,用于把整条调用链的日志串起来。第二个是X-Deadline,通常用 Unix 时间戳或者剩余毫秒数,下游服务拿到之后会重新计算自己的超时时间。第三个是X-User-Context,放 userId、language、timezone 这些不影响接口入参但会影响处理逻辑的字段。

这三件套需要由框架统一处理,千万不要让每个业务开发手动加。手动加会出现两类问题:一类是漏传,某个中间环节忘了把 Header 透传下去,后面所有日志都断链;另一类是乱传,有人把整个业务对象序列化塞进 Header,超过代理服务器对 Header 大小的限制,直接被网关拒绝。在我维护过的项目里,这两个坑都真实出现过,后面踩坑章节我会详细说。

2.3 为什么不建议用全局变量代替 context

很多初学者觉得 context 麻烦:明明一个全局变量就能拿到当前用户,为什么要费劲地在函数签名里多传一个参数?

我的回答是:全局变量解决不了并发。一个高并发的 Web 服务里,同一个全局变量在任意时刻可能有成百上千个请求在同时读写,你根本不知道你读到的是哪个请求的数据。虽然像 Java 的ThreadLocal、Python 的contextvars这种设计在单线程异步里有近似全局变量的用法,但它们本质上还是把变量绑定到当前执行上下文,而不是真的全局共享。

更关键的是,全局变量破坏了函数的纯度。一个函数读全局变量,你就没法从签名上判断它依赖了什么,测试的时候还得小心翼翼地设置全局状态。而把 context 作为显式参数传入,函数的依赖一目了然,测试时传一个构造好的 context 就行。这也是为什么 Go 官方把接口设计成context.Context并且要求把它作为第一个参数传入——用显式参数代替隐式全局态,是工程上最稳的决策。

3. 大模型提示词里的 context-mode:让模型记住该记住的

3.1 同样模型,效果差异来自上下文组织

聊完服务端并发,我们把视角切到 AI 应用开发。如果你做过几个基于大模型的产品,一定遇到过这种情况:同一个模型,同一个用户问题,有人封装出来的效果惊艳,有人做出来的就是段废话生成器。差异往往不在模型本身,而在上下文组织上。

举个例子。用户问“帮我写一封英文请假邮件”。低级做法是把这句话直接丢给模型,模型确实能写,但不知道你的身份、不知道你和老板的关系、不知道请假的理由和时间段,写出来的东西只能靠猜。高级做法是在系统提示词里加上你的姓名、职位、公司文化背景、请假起止日期、是否需要抄送同事,甚至附上一封以前写过的高质量邮件作为风格参考。这些信息就是模型的上下文,而确定放哪些信息、以什么格式放,就是 context-mode 要解决的问题。

我自己做 AI 助手时发现一个规律:模型理解效果 40% 取决于指令本身,60% 取决于上下文信息是否到位。指令只是告诉模型“做什么”,上下文让模型知道“基于什么来做”。很多提示词模板写不好,不是指令表达有问题,而是没有把足够的背景信息填充进去。

3.2 上下文窗口不是越大越好

显存、成本与回复质量三个因素决定了你不能盲目堆上下文。

先看成本。大模型按 token 计费,用户每轮对话都要把之前的上下文全部重新计算一遍,上下文里多 1000 个 token,每轮就多付 1000 个 token 的钱。日活一万的应用,每个用户一天聊 20 轮,上下文多出来的 token 就是 20 万级别的倍乘,日成本瞬间就上去了。

再看质量。模型的注意力机制对长上下文的处理并不是均匀的,信息放在靠前的位置容易被稀释,放在靠后的位置容易被截断。有些模型虽然支持 128K 上下文窗口,但超过一定长度后,中间部分的细节记不住,回复质量明显下降。所以不要因为模型支持长窗口,就真的把什么都往里面塞。

最后看延迟。上下文越长,模型处理的时间越长,用户体验越差。API 的响应时间会随着输入 token 数增长,这是实打实的物理规律。为了省一点拼上下文的开发时间,结果让用户每次多等两三秒,这笔账划不来。

3.3 实操:一套“记忆分层”策略

我自己在大模型应用里常采用一套“记忆分层”策略,非常实用,分享给大家。

第一层叫 System Prompt,放最稳定、最核心的信息。包括角色定义、输出格式要求、公司产品介绍、对话规则。这一层几乎不变,每次请求都会完整携带。

第二层叫会话摘要层。如果历史对话超过一定轮数,我会用模型把之前的内容压缩成 300-500 字的结构化摘要,比如“用户已经确认了需求,反馈预算不超过 3 万元,偏好南方城市”。下一轮对话时,把摘要放在历史消息前面,而不是把每一轮原始对话都塞进去。

第三层叫短期记忆层。只保留最近 5 到 10 轮原始对话。这些消息最贴近当前意图,信息密度最高,放原始内容最合适。

第四层叫知识检索层。根据当前用户问题,用向量检索从知识库拉取最相关的 3 到 5 个片段,按相关度排序插入上下文。绝不要把整个知识库都喂给模型。

你可以把这几层想象成一个四层抽屉的柜子。常用的工具放随手能拿到的抽屉,偶尔用的放中层,一年用一次的放最底层。模型每轮看到的上下文,就是我按优先级从四层抽屉里挑选出来的一组东西。这一套下来,既控制了 token 成本,又比无脑塞完整历史的方案稳定得多。

4. 代码实现:手写一个极简 context-mode 工具

4.1 需求清单与接口设计

理论讲再多,不如直接写代码。我在这里演示一个极简的 Python context-mode 工具,名字就叫ctxmode。它只做四件事:保存上下文值、生成并传递 traceId、设置超时 deadline、在超时后主动取消子任务。

接口设计上我参考了 Go context 的风格,但没有抄它的复杂结构。我只需要提供一个根上下文对象、一个装饰器、一个静态方法current()用于在任意位置获取当前上下文。为什么用装饰器而不是显式传参?因为在 asyncio 里,我们借助contextvars的自动传递能力,可以让子任务自动继承父任务的上下文,这比显式传参更接近真实框架的体验。

使用时的代码大概长这样:

import asyncio import time from ctxmode import ContextMode, run_in_context async def fetch_inventory(product_id: str) -> str: ctx = ContextMode.current() assert ctx.trace_id == "demo-000001" await asyncio.sleep(0.1) return f"inventory-{product_id}" async def fetch_balance(user_id: str) -> str: await asyncio.sleep(0.08) return f"balance-{user_id}" async def main(): root = ContextMode( trace_id="demo-000001", deadline=time.monotonic() + 1.0, user_id="alice", tenant_id="t-100", ) results = await asyncio.gather( run_in_context(parent_ctx=root)(fetch_inventory)("SKU-7788"), run_in_context(parent_ctx=root)(fetch_balance)("alice"), ) print(results) asyncio.run(main())

这个使用方式足够直观。接下来看核心实现,我会把代码拆成几段,一一说明。

4.2 核心实现:ContextMode 类

# ctxmode.py import asyncio import contextvars import time import uuid _state: contextvars.ContextVar = contextvars.ContextVar("ctxmode", default=None) class ContextMode: """极简上下文对象:携带值、traceId、deadline。""" def __init__(self, trace_id=None, deadline=None, **values): self.trace_id = trace_id or uuid.uuid4().hex[:12] self.deadline = deadline self.values = values def child(self, **extra_values): """基于当前上下文创建一个子上下文,traceId 保持不变。""" merged = dict(self.values) merged.update(extra_values) return ContextMode( trace_id=self.trace_id, deadline=self.deadline, **merged ) @property def expired(self) -> bool: return self.deadline is not None and time.monotonic() > self.deadline @property def remaining_ms(self) -> float: if self.deadline is None: return float("inf") return max(0.0, (self.deadline - time.monotonic()) * 1000) def set(self): _state.set(self) @staticmethod def current() -> "ContextMode": ctx = _state.get() if ctx is None: raise RuntimeError("ctxmode: no context set in current scope") return ctx

ContextMode类里的deadline我故意设计成一个绝对时间点,而不是相对秒数。为什么?因为相对秒数在跨协程、跨服务传递时容易失真:你在父任务里设置了 5 秒,但父任务已经跑了 3 秒才创建子任务,子任务如果拿着“再给我 5 秒”就会超预算。用绝对时间点time.monotonic() + 5这样的值,无论在哪一层,只要判断当前时间和 deadline 的关系,就能准确知道这个任务还有多少时间余量。这个设计是所有超时控制的核心。

4.3 装饰器 run_in_context:自动继承父任务上下文

def run_in_context(parent_ctx: ContextMode = None): """为异步函数提供 context 上下文,并做超时约束。""" def decorator(func): async def wrapper(*args, **kwargs): ctx = parent_ctx if ctx is None: ctx = _state.get() if ctx is None: raise RuntimeError("ctxmode: no parent context") if ctx.expired: raise TimeoutError("ctxmode: deadline exceeded before start") ctx.set() timeout = None if ctx.deadline is None else max(0.0, ctx.remaining_ms / 1000) try: if timeout is not None: return await asyncio.wait_for(func(*args, **kwargs), timeout=timeout) return await func(*args, **kwargs) except asyncio.TimeoutError: raise TimeoutError(f"ctxmode: deadline exceeded, trace_id={ctx.trace_id}") return wrapper return decorator

这段代码里最关键的是contextvars的自动传递机制。在 Python 的 asyncio 中,当你用asyncio.gather、asyncio.create_task创建子任务时,子任务默认会拷贝当前 contextvar 状态。我们在 wrapper 里执行ctx.set(),等价于把 this context 绑定到当前执行环境;里面再创建的任何子任务都会自动带着这份状态。所以你在fetch_inventory里直接调用ContextMode.current()就能拿到父任务设置好的 context。

超时部分我用了asyncio.wait_for,但这有个需要注意的地方:wait_for超时后,会向被包裹的协程发送取消信号,而不是强杀线程。如果你的业务代码里存在同步阻塞调用,比如用requests.get()而不是异步客户端,取消信号可能无法及时中断阻塞的线程。这是 Python 并发编程里经典的“超时控制失效”问题,后面我会讲怎么排查。

4.4 用日志验证传递链路

写完核心实现,我习惯立刻用一段带日志的测试代码验证链路是否真的串起来了。我会在每个函数里打印当前上下文里的 traceId 和剩余毫秒数,模拟一个三级的调用链。

import asyncio import logging import time logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") log = logging.getLogger("demo") async def level1(): ctx = ContextMode.current() log.info("level1 trace=%s remaining_ms=%.2f", ctx.trace_id, ctx.remaining_ms) await asyncio.sleep(0.2) async def root(): ctx = ContextMode( trace_id="trace-abc-123", deadline=time.monotonic() + 0.5, ) log.info("root start trace=%s", ctx.trace_id) await run_in_context(parent_ctx=ctx)(level1)() await asyncio.sleep(0.4) log.info("root done") asyncio.run(root())

跑下来你会发现,level1 拿到的 traceId 和根上下文完全一致,剩余毫秒数会随着等待而减少。这就是 context-mode 的价值:日志里任何一条记录,只要你打印了trace_id,就能看出它属于哪一次请求的哪一段执行链。我在微服务日志系统里都是按这个思路设计的,排查线上问题从“大海捞针”变成“按图索骥”。

5. 踩坑记录:context-mode 最常见的五个问题

5.1 超时明明设置了却不生效

这是我在实际项目中遇到最多的问题。代码里设置了deadline,但任务到了超时时间还在继续执行。最常见的三个原因:

第一个原因是使用了同步阻塞库。如果你的子任务里调用了一个同步的requests.get(),asyncio.wait_for的超时信号只能取消协程,但线程仍然阻塞在那个调用上。表现出来就是任务像是在运行,实际上协程已经被取消标记了,只是阻塞调用迟迟不返回。解决办法是把同步调用丢到线程池里执行,或者换成httpx.AsyncClient、aiohttp这类异步客户端。

第二个原因是asyncio.wait_for被再包装了一层但timeout=None。你如果在外层 wrapper 里计算 timeout 时写错了,比如把remaining_ms当成秒传给了wait_for,那实际超时时间会被放大 1000 倍,看起来就是“设置了等于没设置”。我的经验是写日志时把remaining_ms打出来,肉眼对一下数量级。

第三个原因更隐蔽:你在子任务里又重新创建了一个 context,覆盖了父任务的 deadline。很多团队会在子任务里ContextMode.current().child(user_id="xxx"),如果child()方法忘了继承 deadline,子任务就变成了“无超时约束”。我在代码实现里特别让child()保留 deadline,就是为了防止这种覆盖。建议所有封装 context 的方法都遵循“子上下文继承所有约束字段”的原则。

5.2 traceId 串线、请求日志对不上

跨服务调用时的 traceId 丢失和串线,是最让人头疼的问题之一。串线的典型场景是:线程池复用了线程,而 traceId 被存在了 ThreadLocal 里,线程跑完一个任务后没有被清理,下一个任务拿到的是上一个请求的 traceId。

解决办法有两个。一是用contextvars而不是裸的ThreadLocal,这样每个 async Task 都有独立的上下文;二是在线程池任务执行的入口处强制显式设置 traceId,任务结束前用try/finally清理。我自己的代码里,凡是把任务丢进线程池做阻塞 IO 的,都会写一个适配器:入池前取出父 context 里的 traceId,入池后重新设置到当前线程的 contextvar,任务结束后再恢复。别嫌麻烦,这个步骤能省下你大量排查日志的时间。

另一个容易踩的坑是 HTTP 客户端自动携带 Header。有些 HTTP 框架为了“方便”,会自动转发所有入站请求的 Header 到下游。这听起来省事,但会把内部的 traceId 意外暴露给第三方服务,同时也可能把用户自定义 Header 到处传播,引发安全风险。我建议默认不自动转发,只在网关层或者 Service Mesh 层统一处理这三个头:X-Trace-Id、X-Deadline、X-User-Context。

5.3 把 context 塞进全局缓存导致内存泄漏

这个坑我和同事踩得特别深。自动化任务系统里,有个同事为了省事,把请求上下文里的业务数据直接丢进了一个全局字典缓存里,想着后面还能用。结果因为 context 里塞了一个数据库连接池的引用,这个全局字典一直不释放,内存曲线呈阶梯式上涨,最终 OOM。

context 的生命周期必须和请求一致。请求结束,context 就应该被释放,里面引用的对象也应该能被 GC 回收。千万不要把 context 对象本身存到全局长期存活的容器里,除非你非常清楚自己在做什么。如果你需要缓存请求级数据,应该把数据提取出来,转换成值对象再缓存;如果只是临时算一下结果,用完即丢。

判断内存是否泄漏,我有个经验:在测试环境连续跑一万次模拟请求,观察常驻内存涨不涨。如果内存趋势是稳定平台期,说明 GC 正常;如果持续线性上涨,八成是某个全局容器持有了一堆 context 或请求对象,去查那些定义为static、global、模块级变量的容器就行。

5.4 排查思路速查表

为了方便大家对照排查,我把常见的 context 问题整理成一张表:

现象可能原因排查方向
超时没生效同步阻塞调用检查线程池/异步客户端
超时没生效timeout 单位配错打印 remaining_ms 数量级
超时没生效子上下文覆盖 deadline检查 child() 继承逻辑
traceId 串线ThreadLocal 未清理检查线程池适配器
traceId 断链Header 没透传检查网关/Service Mesh 配置
上下文数据错乱context 被全局容器缓存检查全局字典持有
内存持续增长context 引用大量对象用 heap profile 定位引用链
日志无法关联只打了日志没打 traceId日志框架注入 traceId

这张表不是教科书理论,都是我实际踩过的坑。建议你把这张表贴到团队 wiki 里,新人看到能少走很多弯路。

6. 实际项目里怎么决定要不要上 context-mode

6.1 三个信号说明你确实需要它

我们经常在代码评审里争论:大家都觉得 context 很优雅,但什么时候必须上、什么时候是过度设计,其实需要判断依据。我总结出三个强信号,命中任一个就值得引入。

第一个信号是你项目里出现了显式的超时参数在函数签名里层层传递,比如async def fetch(session, timeout)然后 timeout 从入口函数传到最里层的数据库调用。出现这种代码,说明你要共享 deadline 语义,这正是 context 的核心场景。把 timeout 放进 context 而不是当一个参数传,可以避免中途新增子任务时忘记传入超时参数。

第二个信号是日志需要跨函数、跨服务串联。当你开始手动维护一个请求 ID,并且要在所有日志里打印它,手填极易出错。用 context 自动携带 traceId,一道装饰器就能解决。

第三个信号是业务处理链路是 DAG 类型。一个请求会同时触发多个子任务,子任务之间还可能相互等待。如果没有一个统一的数据载体来存放用户、租户、traceId 等信息,你会发现自己写的每个函数都变成了“前面八个参数是基础设施,后面才是业务参数”,代码可读性直线下降。

6.2 什么时候别用

context-mode 也不是万能药。如果你的项目只是个简单的 CRUD 接口,函数调用层级不超过三层,业务之间共享的状态只有一两个字段,那我建议别折腾,直接传参最清晰。引入 context 会提高抽象层级,新手读懂的成本更高,调试时也得额外关注隐式状态。

另外,如果你用的是无异步、纯同步的高性能并发模型,比如 Java 的虚拟线程还没普及的旧版本,使用ThreadLocal时要格外小心线程池复用的坑。在这种情况下,比起普及 context 模式,先把日志里的 requestId 保证正确更实际。

我见过一个项目,负责人强行把 context 模式引入到一个只有两个模块的项目里,结果代码里到处都是ContextMode.current(),业务方法里面还在各种 set,阅读体验非常差。抽象的作用是解决问题,而不是制造问题。

6.3 后续扩展:从单体到分布式的一小步

如果你已经在单体服务里做好了 context-mode 的统一管理,那下一步的自然扩展是把它推广到分布式链路。做这件事有三个顺序。

第一步是统一 traceId 生成和透传规范。确定关键 Header 名称、格式、传递范围,用中间件统一处理入站和出站请求。第二步是接入链路追踪系统。如果你的技术栈允许,可以直接用 OpenTelemetry,它的 context 管理和 context propagation 设计非常成熟,你可以把自己队里的 context 设计向它靠拢,甚至直接替换掉手写版。第三步是结合 metrics 做容量预算。当你能准确拿到每一跳的耗时,就能计算出下游整体超时预算如何分配,做到精细化的超时控制。

我不建议直接上流量很大的分布式系统时从零开始自研 context 中间件,太容易出事。先从单体服务里把语感练好,再逐步替换成成熟方案,路径最稳。

我个人在实际操作中还有一个很深的体会:context-mode 的代码写起来容易,难的是让所有人理解它的边界。如果你是团队的骨干,别只把实现代码丢给同事,一定要专门写清楚“什么数据应该放 context、什么数据不应该”。比如请求级用户信息适合放,但每次查询的结果数据不适合放;超时时间适合放,但全局配置表不适合放。边界画清楚,这个模式才不会成为另一个“谁都不敢动”的抽象陷阱。