资讯详情

资讯详情

context-mode实战:从用户数据串号到上下文工程

三年前那次线上告警我到现在还记得。那是一个多租户后台管理系统晚上十一点左右陆续有用户反馈在订单列表里看到了别人的收货地址。第一时间怀疑缓存串了可查遍 Redis 也没发现问题。后来顺着调用链一步一步查下去才确认根源出在一次内部服务调用的参数传递上上游把 user_id 和 tenant_id 传给了中台中台在调用下游内部接口时漏掉了 tenant_id结果下游按默认租户兜底处理把整张订单表的数据都抖了出来。那个团队当时所有接口都是显式传参在传递用户身份每个服务每个方法都要在签名里加参数。看起来没问题实际上全靠人的自觉在维护一条看不见的链路。一次漏传就是一条数据事故。也就是从那天起我彻底理解了 context-mode 这类上下文模式的价值它要做的事情不是简单帮你少写几个参数而是把当前这单业务到底是谁发起的日志应该怎么串起来这类状态从业务代码里剥离出来让它们在请求的生命周期内自动流动、自动销毁。这篇文章我用自己实际踩过的坑来拆解 context-mode它解决的核心矛盾、三种常见实现形态、我重构过的具体方案、LLM 应用里的上下文窗口管理以及一次上下文串号的完整排查过程。适合正在做后端服务、微服务治理或者在做大模型应用工程的读者参考。1. 从用户数据串号说起context-mode 到底解决了什么1.1 事故现场复盘先说那个事故的具体链路方便后面理解上下文模式的必要性。系统有三个服务网关 A订单中台 B仓储服务 C。用户在网关 A 登录后A 解析出 user_id 和 tenant_id调用 B 的查询订单接口时把这两个参数传了过去。B 的业务代码处理到一半内部又调用了 C 的一个接口去查库存状态但调用 C 时只传了 order_id没传 tenant_id。C 的接口按未显式传租户则使用默认租户的逻辑执行于是返回了默认租户下的库存数据。B 拿到错数据后并不觉得有异常拼装后返回给前端用户就看到别人的库存、别人的地址。事后复盘时我们画了一张调用链图发现从网关到最终数据库这条链路要经过至少四次内部调用。任何一层漏掉一个身份参数数据就会串。这不是某一个人的粗心而是系统设计上没有把上下文当做一个一等公民来对待。显式传参把所有状态都压在了业务方法的签名上方法越多、链路越长就越容易漏。1.2 context-mode 的正式定义context-mode上下文模式指的是一种跨函数、跨模块、甚至跨服务携带当前处理上下文的机制。这个上下文通常包含当前用户身份、租户标识、TraceId、请求来源、语言区域、超时策略等不是业务数据本身而是业务处理所需的环境信息。它的核心价值有三个穿透传递底层函数不需要在方法签名里显式声明我是给哪个用户在处理随时可以从上下文中取到当前身份信息。自动跟随执行流无论是同步调用、异步任务还是协程切换上下文都能跟随着这一次业务请求走不会跟其他请求混在一起。边界清晰、自动释放请求开始创建上下文请求结束销毁上下文。同一个请求内共享不同请求之间隔离。一句话概括context-mode 把参数传递从职责里解放出来让关注点回到具体的业务逻辑上。它适合传递身份、租户、链路追踪等元数据不适合塞入大对象、大体积数据或数据库连接这类重量级资源。2. 三种实现形态显式穿透、自动注入、全局共享2.1 显式穿透最朴素的做法也是事故的起点显式穿透就是最常见的一种context 作为参数的方式。在 Go 里你会看到ctx context.Context被作为每个函数的第一个参数传递在 Java 里可能是RequestContext对象被层层传递在 Python 里可能是request参数一路穿到底。func GetOrder(ctx context.Context, orderID string) (*Order, error) { userID : ctx.Value(user_id).(string) tenantID : ctx.Value(tenant_id).(string) // 业务逻辑 }这种方式的好处是依赖关系非常清晰代码里能直接看到这个函数依赖于上下文里的哪些数据测试时也方便手动构造 context。但它有两个明显问题第一侵入性强。业务方法签名的第一个参数永远要挂着 context接口定义被污染看起来不干净。第二传递链靠自觉维护。任何一个中间环节忘记传参整条链就断了编译期不一定能发现运行期才会暴露成数据事故。而且层级越多这种断链的风险越大。所以我的判断是函数式小项目、层级不超过两层、团队规模小的时候显式穿透完全够用。一旦服务开始拆分、调用链拉长就要认真考虑更自动化的上下文方案。2.2 自动注入框架在入口处把上下文塞进去自动注入是显式穿透的升级版思路是不在业务方法里传 ctx而是用一个中间件或过滤器在请求进入服务的第一时间把身份数据解析出来塞进一个请求级别的上下文对象里业务代码想用的时候主动去取。以 FastAPI 为例app.middleware(http) async def user_context_middleware(request: Request, call_next): request.state.user_id parse_user_from_header(request) request.state.tenant_id parse_tenant_from_header(request) request.state.trace_id request.headers.get(X-Trace-Id, generate_trace_id()) response await call_next(request) response.headers[X-Trace-Id] request.state.trace_id return response业务代码里就不用再逐个接口传参了def get_order(request: Request): user_id request.state.user_id tenant_id request.state.tenant_id ...这类方案的优点是统一入口、统一解析、统一注入团队里不再比拼谁记得传参而是靠框架保证。缺点是它通常绑定在某一个 Web 框架上如果业务里有消息队列消费者、定时任务、异步事件处理器这些非 HTTP 入口就得另外处理。它是 context-mode 落地的第一步但还不是终点。2.3 全局共享ThreadLocal、AsyncLocal 与它的代价真正让 context-mode 变得好用的是全局共享式的实现。它利用语言运行时提供的线程局部存储或协程局部存储能力为每个执行流隔离一份上下文数据。Java 里有ThreadLocal配合InheritableThreadLocal或者阿里开源的TransmittableThreadLocal处理线程池场景Python 里有contextvars.ContextVarNode.js 里有AsyncLocalStorageGo 里通常还是用显式context.Context或者配合框架做自动注入。这类实现的核心机制是程序运行时有主线程、子线程、线程池、协程、异步事件循环每个逻辑执行流都有自己的上下文副本。你在入口处往上下文里写入 user_id底层异步函数读取时拿到的就是当前这条执行流的值不会串到别的请求上去。但这把双刃剑的锋利面也很明显上下文变成了隐形的全局变量调试起来比显式传参要费劲一些。更严重的是如果环境里用了线程池任务执行完没有及时清理上下文线程复用时就会把上一个请求的上下文带到下一个请求里出现串号事故。这在第 5 节我会用完整的排查过程来讲。三种形态放在一起对比更直观实现形态数据流向典型实现异步安全主要风险显式穿透参数逐层下传Go context、Java 参数透传天然安全易漏传、侵入业务签名自动注入框架入口写入请求对象FastAPI state、Spring HandlerInterceptor取决于框架实现仅覆盖 HTTP 入口全局共享执行流本地存储ThreadLocal、contextvars、AsyncLocalStorage需要选对 API清理不及时导致串号3. 我基于 contextvars 重构一个请求追踪模块的完整方案3.1 重构前的痛点那个事故之后我们做了一个内部复盘决定先在订单中台引入统一的请求追踪机制。当时的诉求很明确每个请求进入服务后必须有一个 TraceId所有日志都要带上这个 TraceId方便从网关入口一路追踪到数据库调用。但现状是 TraceId 也是靠参数传的。Service 里每个方法都要声明一个 traceId 参数为了日志还要手动塞进日志字段做了几次代码审计发现至少有十几个方法漏了。日志关联率不到 90%排查问题只能靠猜。我们定了三条约束不改业务方法的签名所有入口HTTP、MQ、定时任务都能生效支持异步场景。最终选了 Python 的contextvars来实现而不是 ThreadLocal。原因是我们的服务基于 asynciocontextvars是原生协程局部变量异步任务之间自动隔离不需要像 ThreadLocal 那样手动关心线程切换。3.2 核心实现骨架代码很简单核心模块就几十行# context_utils.py import contextvars import uuid request_context_var contextvars.ContextVar(request_context, defaultNone) def init_request_context(*, trace_idNone, user_idNone, tenant_idNone): 在请求入口创建上下文返回 reset 用的 token。 if trace_id is None: trace_id uuid.uuid4().hex ctx { trace_id: trace_id, user_id: user_id, tenant_id: tenant_id, } return request_context_var.set(ctx) def get_request_context(): 获取当前上下文字典没初始化时返回空字典尽量保证调用方不用写 None 判断。 ctx request_context_var.get() return ctx if ctx is not None else {} def get_trace_id(): return get_request_context().get(trace_id, -) def reset_request_context(token): 请求结束时销毁上下文。 request_context_var.reset(token)然后在 FastAPI 中间件里接入# middleware.py from fastapi import Request from context_utils import init_request_context, reset_request_context, get_trace_id app.middleware(http) async def request_context_middleware(request: Request, call_next): token init_request_context( trace_idrequest.headers.get(X-Trace-Id), user_idparse_user_id(request), tenant_idparse_tenant_id(request), ) try: response await call_next(request) response.headers[X-Trace-Id] get_trace_id() return response finally: reset_request_context(token)MQ 消费场景同理在消费函数入口调用init_request_context结束时 reset。业务代码想记日志时不再需要手动传 TraceId直接写成logger.info(订单创建成功, extra{trace_id: get_trace_id(), order_id: order_id})我强调的是contextvars的set和reset必须成对出现而且必须在finally里 reset。如果你在正常流程尾部 reset遇到异常抛出的情况token 就没有被消耗轻则上下文残留重则下一个复用这个执行流的请求读到上一个请求的数据。3.3 落地效果与几个容易忽略的细节改造上线后日志关联率从不到 90% 提升到了 99.7%剩下那 0.3% 主要是极少量的初始化异常场景可以继续通过拦截器兜底。业务方法签名没有任何改动之前几十处 traceId 参数全部删掉Code Review 的压力小了很多。但也遇到两个有意思的细节。第一有些开发同学会把业务数据塞进 context比如把整个 user 对象、几十 MB 的文件内容放进去。这在contextvars里不会编译报错但会让每个协程切换时都保留一份引用内存和 GC 压力上升。我们后来在代码规范里明确规定context 里只能放标量 ID、枚举值、字符串不允许放复杂对象。第二如果中间件里 try 块内出现了asyncio.create_task创建的后台任务contextvars会正确复制上下文到新任务中这一点体验比 ThreadLocal 好很多。但要注意的是后台任务如果存活时间超过请求本身它持有的上下文引用持续时间也会超过请求这种场景最好主动在任务入口重新设置一份上下文而不是继续依赖上游拷贝过来的副本。4. LLM 应用里的 context-mode上下文窗口总是不够用怎么办4.1 上下文窗口不是越大越好把 context-mode 的概念挪到大模型应用领域你会发现一个很有意思的对应传统服务讨论的是如何跨函数传递状态LLM 应用讨论的是如何把有限上下文窗口里塞进最有价值的信息。很多同学最初的想法是模型不是支持 128K、200K、甚至 1M 上下文吗那把整本操作手册、完整聊天记录全部塞进去不就行了。实际上真不是这样至少我实测下来有三个问题肉眼可见成本Token 按输入量计费每多塞一万字每次请求就多花一份钱。高频调用时这是非常可观的数字。延迟长上下文意味着模型每一轮都要处理大量历史 Token首字延迟增加对对话体验是负分。注意力稀释上下文越长模型越容易忽略中段信息。业界有过不少实验把相关内容放在极长上下文的中间位置模型找不到的概率明显上升。所以 LLM 应用中的 context-mode本质是一种上下文工程你怎么设计 prompt 的组成、历史对话怎么保留、知识库内容按什么策略放进上下文。4.2 几种典型的上下文管理模式我做了这么久 LLM 应用实际用下来有效的方法大概有四种第一滑动窗口。只保留最近 N 轮对话更早的全部截断。实现简单适合闲聊类场景但缺点是最初的几条关键约定可能被挤掉。第二摘要压缩。把超出窗口的旧历史先用模型提炼成一段摘要对话时只放摘要 最近 N 轮原文。适合客服助手既有长期记忆又有短期细节。第三RAG 按需召回。知识库内容不全部塞入而是先对用户问题做向量检索只把最相关的几段拼进上下文。这是目前知识问答类应用的主流做法。第四工具动态选择。系统里配置了几十个工具定义每个定义都有几百 token。与其全部塞进 tools 列表不如先做一轮工具意图识别只把当前对话可能用到的几个工具定义放进上下文。一个简化的代码思路def build_messages(history, recent_limit6, token_limit4000): recent history[-recent_limit:] older history[:-recent_limit] digest summarize_history(older) if older else None messages [] if digest: messages.append({role: system, content: f历史摘要{digest}}) messages.extend(recent) # 校验总 token如果还是超限继续对 recent 头部做摘要 while estimate_tokens(messages) token_limit: messages compress_oldest(messages) return messages核心逻辑就一句话能不放就不放不得不放时优先压缩。4.3 我踩过的坑把 80 页 PDF 全文塞进上下文有一次我们做一个合同审查助手开发同学图省事把一份 80 页 PDF 转成文本后直接塞进 system prompt然后问模型这份合同的违约金条款是什么。模型一本正经地回答合同中未明确约定违约金条款但实际上合同第 47 页白纸黑字写着。我们把模型换成了更强的大参数版本效果依然不稳定。后来把全文从 prompt 里移出去改成对合同先做章节切分和关键条款抽取把抽取结果存成结构化索引回答问题时先检索到违约责任章节再把那 2 页内容放入上下文。同样的问题回答准确率一下子就上来了。这个案例给我的教训很深刻长上下文模式的正确用法不是把所有内容都堆进去而是用程序先做一轮上下文加工只把模型真正需要的那部分放进其视野。上下文工程是 context-mode 在 AI 时代最重要的实践方向你能省下的不仅是一半的 token 成本还有大量因为模型看不到重点而产生的幻觉问题。5. 排查实录上下文串号后我是怎么定位根因的5.1 现象和第一反应有一次我们给另一个 Java 服务接入了一个基于 ThreadLocal 实现的 context 组件压测阶段就出了怪事。测试人员用两个账号并发访问A 账号查自己的订单响应里偶尔会出现 B 账号的订单数据。开发和测试反复验证了单账号场景一切正常只有高并发并发才随机出现。第一反应是出了数据事故甚至有人开始怀疑是不是数据库查询条件拼接错误。但检查 SQL 日志发现同一个 SQL 条件里的 tenant_id 时对时错说明问题不在数据库层而在调用层。当时我们几个人围着监控面板谁都没第一时间联想到 ThreadLocal 身上——但事后看方向已经很清晰了。5.2 一步步走到根因排查链路大概花了半天我把它完整复盘出来给大家参考第一步加日志确认交叉点。服务里给每个请求都打了start和end两条日志里面包含 traceId、userId、tenantId。压测后翻日志发现同一个线程 ID 上前一条日志还是 A 用户的 tenantId后一条日志就变成了 B 用户的 tenantId。这直接指向线程复用而不是业务代码查询条件错了。第二步查线程池。服务用的连接池和业务线程池压测并发高线程确实被大量复用。此时在业务代码里加一个临时日志打印Thread.currentThread().getId()以及从 ThreadLocal 里取出的 tenantId重新压测。结果发现大量线程上的 ThreadLocal 值在任务结束后没有被 remove。第三步查继承逻辑。我们的实际场景是A 请求的子线程任务被提交到线程池任务里读到了 ThreadLocal 里的 tenantId 没问题。但任务执行完线程池里这个工作线程没有清理 ThreadLocal下一次被 B 请求的任务复用B 任务开头又没有主动 set context于是读到了 A 残留的 tenantId。这是教科书级的跨线程上下文串号。根因一言以蔽之ThreadLocal 是按线程维度的不是按请求维度。线程池里的线程被多个请求交错复用如果不做清理残留上下文就会串号。5.3 修复方案和验证过程修复方案做了三件事第一所有任务提交到线程池时使用TransmittableThreadLocal或者手动拷贝上下文让每个子任务拿到属于自己的副本。第二在任务的 finally 块里显式 remove ThreadLocal 中的 context。第三能用协程级上下文方案的尽量切换到按逻辑执行流隔离的方案比如 Python contextvars、Node AsyncLocalStorage这类方案天然会跟随逻辑执行流不会有线程复用残留。验证过程也分享出来。我们写了一个并发断言脚本构造 N 个请求每个请求的 tenantId 是唯一值断言每个请求的服务端任务处理过程中的 tenantId 始终等于自身。脚本持续跑十分钟一旦发现不一致立即触发告警。修复前这个脚本三分钟以内必报警修复后连续跑一周零告警。再配合当时的压测回归数据串号问题彻底消失。这条排查链路对我的意义很大。很多团队遇到上下文串号会本能地往数据库、缓存方向查但真正的根因往往就在最基础的执行流隔离上。正则表达式式地“找哪里设置错了”不如先确认你对“执行流”的理解对不对。6. 我坚持的三条设计原则与实际体会6.1 原则一上下文只放跟链路有关的小型元数据身份 ID、租户 ID、TraceId、语言、时区、请求来源、当前用户角色这些是典型的上下文数据。它们是描述这次处理是谁发起的的元数据不是业务产物。大对象、数据库连接、文件句柄、消息体都不应该放进去。这个原则能保证 context 本身轻盈、安全。如果有一天你把一个 10MB 的图片对象放进 context第一个受害者是内存第二个受害者是排查问题的人——他们打开上下文快照时会被巨大对象淹没。6.2 原则二谁创建谁销毁销毁必须写在 finally 里无论你用 ThreadLocal、contextvars 还是 AsyncLocalStorage创建和销毁的配对关系都要放在同一个入口函数里。我的习惯是token init_request_context(...) try: ... finally: reset_request_context(token)如果有人问在哪里销毁最合适我的答案永远是在创建它的这个函数里、通过 finally 来销毁。不要靠另一个模块顺手清理不要靠 GC更不要靠重启。上下文一旦没有明确销毁路径就等于给未来的串号事故埋雷。6.3 原则三上下文必须可观测引入了 context-mode 之后最怕的就是它变成隐形依赖——代码里根本看不到谁在用哪个 key问题出现时很难追。所以我给团队定了一个硬性要求context 里的每一个字段都要有明确的语义和埋点请求的入口处必须打印上下文摘要日志必须带 traceId。你可以为 context 单独加一个 debug 接口收到请求时输出当前上下文里所有 key 的名字和值长度不要把具体值全部打出来防止敏感信息泄漏但至少让排查者第一时间知道这个请求的上下文里有什么、缺什么。这一层可观测性是 context-mode 不至于沦为黑盒魔法的底线。另一方面从实践角度我想多说一句如果项目规模还小请不要一上来就上一套全局共享式 context。显式传参虽然丑但它是好理解的、好排查的。等真的出现参数怎么传也传不完的痛感再用 context-mode 重构你会更清楚自己为什么需要它而不是为了高级而高级。这几年我把 context-mode 分别用在了传统后端服务和 LLM 应用工程里两份场景差异很大但底层思考是一致的状态要有边界数据要在合适的时机流到合适的位置并且这一切必须可观测、可销毁。希望能给正在做相关设计的你一些借鉴。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →