ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从无状态到有状态:context-mode上下文模式的设计与实战

从无状态到有状态:context-mode上下文模式的设计与实战

我一直觉得,"context-mode"这类能力被很多人低估。它不是某个开源项目的专属概念,而是一种极其通用的设计思维:让系统在运行时不只看到眼前这一条消息,还能把历史、环境、前置结果一起带进来。前段时间我帮一个朋友调试AI客服机器人,同样的提示词、同样的接口参数,上午回答正常,下午就开始前后矛盾,最后定位下来就是上下文模式的保存策略出了问题——旧的会话记录被一股脑塞进了新请求,主题都被带偏了。这个排查过程让我特别想把"context-mode"这件事讲透。

这篇文章就围绕 context-mode 展开,适合这几类人看:写命令行工具和脚本的开发者、做大模型应用(提示词、RAG、Agent)的工程人员,以及所有被"无状态接口"折腾过的运维和测试同学。我会从它解决什么问题讲起,再拆常见实现,最后把我实测过的坑和优化方案一并分享出来,全程以实际可复现的代码和步骤为主。

1. 从"无状态"到"有状态":context-mode到底解决什么问题

1.1 无状态模式的天然短板

很多接口和命令行的设计哲学是"无状态":每次调用完全独立,调用方把所需信息全部传进来,系统不记忆任何历史。拿一个最典型的例子来说,你用post请求查询某天的错误日志,curl https://api.example.com/logs?date=2024-01-15,返回结果里不包含上一天的对比,也不包含"你上一次查询了哪个时间范围"这类信息。

这在单次查询场景没有任何问题,可一旦你想做多轮任务就卡住了。比如我常写的日志分析脚本:第一步统计某时间段的错误总数,第二步要在第一步结果的基础上做关键词过滤,第三步再对比前两步的差异。没有上下文模式的话,第二步必须重新把第一步的全量日志拉一遍,或者在脚本内部把中间结果存到临时变量、临时文件、环境变量里。术语叫"调用方自行管理状态",说白了就是失忆的接待员,每个客人都要重新做完自我介绍,效率低且极易出错。

这也是为什么大型系统演进到一定阶段,都会主动引入某种 context-mode 机制。前端状态管理库(React 的 Context、Redux)就是为了解决组件树之间共享数据的问题;后端微服务里的 traceId 贯穿整个调用链,本质上也是把一个请求的上下文从网关一直传递到数据库层。

1.2 context-mode的核心假设:给程序一张便签纸

所谓 context-mode,核心假设是这三点:

  • 存在一个共享的信息载体,被当前任务的所有参与者共同认可;
  • 这个信息载体是可变的,能随着任务进展被更新;
  • 载体里的内容应当被明确区分:哪些是全局信息、哪些是临时信息、哪些是已失效信息。

我用一个生活类比说明。假设你是一个临时项目组的组长,团队成员互相不认识,如果每个组员都只被下达临时指令,协作肯定一团糟。context-mode 就是那份项目看板:成员清单、当前进度、用户偏好、已完成事项都在上面。做任何决策前先看一眼看板,做完了更新看板,这样每个人在行动时都带着完整背景。

放到技术实现里,便签纸的物理形态千差万别:可以是进程内的一块内存对象,可以是Redis里的一个key,也可以是请求头里的一段JSON,还可以是给大模型的那一大段历史消息。但逻辑上都一样:当前执行单元能读到任务相关的背景信息,而不是每次从零开始。

1.3 为什么很多场景默认不开context-mode

一个很自然的问题是:既然上下文模式这么好,为什么很多工具、接口默认不开启?答案是成本与复杂度,它不是免费的。

上下文保存至少带来三类开销:

  • 存储开销:把历史信息放在内存或磁盘,数据量会随时间线性增长;
  • 传递开销:在分布式系统里,每次调用都要把上下文序列化后塞进消息体或请求头,网络包变大、延迟上浮;
  • 正确性开销:多线程、多实例并发下,上下文一旦共享就会面临竞争和污染问题,排查难度远超无状态模式。

我见过很多折腾过 context-mode 的人最后又回到无状态模式,原因并非它不好用,而是他们陷入了"有状态必须把一切都记下来"的迷思。合理的设计应该是一种折中:无状态作为默认,context-mode 作为一种能力开关,只在需要记忆的任务中启用,并且明确上下文的生命周期。理解这层权衡,比单纯会用某个库重要得多。

2. 几种常见实现:开源框架里context-mode的长相

2.1 编程语言层面的context传递

先看最底层的实现。Go 语言的context.Context是我用过最典型的例子。它贯穿请求的整个生命周期,可以携带超时时间、取消信号、请求级键值数据:

func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 注入一个超时控制 ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() userID := context.WithValue(ctx, "user_id", 12345) result, err := processUser(userID) // ... }

这样处理函数的后续调用链都能拿到同一个 ctx,无论层级多深都能读到超时配置和用户ID。

Python 里有个花式操作叫contextvars。它解决的是异步编程中的上下文问题:多个协程并发执行时,全局变量会互相污染,contextvars.ContextVar允许你为不同任务提供独立的上下文:

import contextvars request_id_var = contextvars.ContextVar("request_id", default="N/A") def process_task(): rid = request_id_var.get() # 当前协程只拿到本任务的 request_id async def handle(req): request_id_var.set(req.id) await process_task() # process_task 内能取到正确的 id

Java 生态里最出名的则是 MDC(Mapped Diagnostic Context),配合日志框架使用,能自动把 requestId、userId 打印到每行日志里,排查问题时非常省事。

2.2 应用框架的会话上下文

再往上走,是各种业务框架的会话与上下文容器。Web 开发里的 Session 就是一种 context-mode,它把用户登录状态、购物车内容、最近浏览记录保存下来,供同一个用户后续请求读取。前端 React 的 Context Provider 也是差不多的逻辑,顶层组件往 Context 里塞数据,下层任意组件都可以消费,免去了逐层传 props 的麻烦。

在数据应用里,常见的是把检索到的文档、用户历史偏好、当前会话id组合成一个上下文对象,传给下游。以我做过的一个日志分析小工具为例,上下文是一个JSON结构,每次处理都会更新它:

{ "session_id": "2024-06-01-001", "date_range": "2024-06-01 00:00:00 ~ 2024-06-01 23:59:59", "error_type_counts": {"timeout": 12, "refused": 5, "crash": 2}, "last_filter_keyword": "timeout" }

第二次处理时,工具会把date_range和last_filter_keyword读出来作为输入,而不是让脚本调用方重新传入。这就是最朴素也最有效的上下文。

2.3 大语言模型里的context-mode

这是近两年我投入精力最多的领域。LLM 的 API 原子能力本来是无状态的,每次调用只根据输入的 messages 生成补全。所谓 context-mode,在模型侧就是把 system prompt、多轮历史消息、工具返回值全部拼进 messages 数组。一个带上下文的调用长这样:

{ "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": "你是客服助手,总是先确认用户意图再回答。"}, {"role": "user", "content": "我的订单三天没到。"}, {"role": "assistant", "content": "请问您的订单编号是多少?"}, {"role": "user", "content": "订单号是12345"}, {"role": "tool", "content": "查得订单12345状态:运输中,预计明天送达"} ] }

这里 messages 就是 star 上下文的最小单位。复杂系统还会在 system 里注入当前时间、用户画像、检索来的文档片段、历史对话摘要。这些内容组装得当,模型就像是"带着前情提要"在回答,否则每个问题都是第一次见面。

我用一张表概括不同尺度的 context 形态:

层级代表实现生命周期典型用途
请求级HTTP头里的request-id单次请求日志追踪、超时控制
会话级Web Session、LLM messages一次用户会话登录状态、多轮对话
应用级全局缓存、Context Provider进程/应用运行期配置下发、主题切换
任务级临时文件、状态对象一次批处理任务数据管道中间结果

3. 实战拆解:给对话和代码分析工具开context-mode

3.1 从命令行工具示例说起

我以一个日志分析脚本的演进过程示范最简上下文。一开始它是无状态的两个命令:

python analyze.py --input app.log --action count_by_type python analyze.py --input app.log --action filter_by_key --keyword timeout

问题在于第二次执行又重新读了一遍日志,大数据量下速度慢,而且两次之间如果日志文件变了,结果就完全不可比。合理的做法是把第一步产出的统计结果持久化成生态文件作为上下文。

用 Python 实现大致是这样:

# step1.py 生成 context import json, datetime log_data = load_logs("app.log") stat = count_by_type(log_data) context = { "generated_at": datetime.datetime.now().isoformat(), "log_file_hash": hash_file("app.log"), "stat": stat, } with open(".context.json", "w") as f: json.dump(context, f, indent=2)
# step2.py 读取 context import json if not os.path.exists(".context.json"): print("ERR: 没有找到上下文,请先执行 step1") sys.exit(1) ctx = json.load(open(".context.json")) print("基于日志散列", ctx["log_file_hash"], "在", ctx["generated_at"], "统计的结果:") for err_type, cnt in ctx["stat"].items(): print(f" {err_type}: {cnt}")

关键是log_file_hash字段,它保证 step2 用的上下文不会对上一份日志产生误判。如果日志文件变了,脚本会提示需要重新生成。这个小设计说明:上下文不只是缓存数据,还必须携带数据的指纹和时效,否则就是拖延出的脏数据。

3.2 在AI提示词工程里手动维护上下文

对接大模型时有个常见误区:以为把完整聊天记录一股脑放进 messages 就是开了一了 context-mode。其实上下文的管理策略远比这精细。

我常用的一套维护流程是这样:

  1. 定义三类信息:系统固定信息(人设、工具描述)、会话历史(多轮问答)、本次具体问题;
  2. 将固定信息和本次问题放在前后两端,历史摘要放在中间;
  3. 历史对话超过阈值后,执行摘要压缩(见第5章)。

一个典型模板:

[系统信息] 你是数据分析师。你可用工具:日志查询、指标统计。 请始终用中文回答,并使用markdown输出结构化结论。 [历史摘要] 用户此前询问了6月1日错误类型分布,发现timeout占比最高。 之后询问了timeout的IP分布,结论是集中在一个网关IP。 上下文当前状态:正在追查该网关为何超时。 [本次问题] 请继续分析:网关超时最可能的根因是什么?结合已给上下文给出排查清单。

每次更新上下文时,我把旧对话里真正有用的结论提取成一个摘要,而不是保留原始的几万token聊天记录。这一步对 token 成本和答案质量都至关重要。实际跑下来,一个5000字的聊天记录,压缩成300字摘要,相关问题的回答准确率还会提升,因为噪音少了。

3.3 工具选择与设计建议

在做技术选型时,我建议按项目阶段来决定上下文用哪种承载方式:

  • 仅本地脚本:用 JSON/文件持久化最简单,能肉眼审计;
  • 单机服务:用 Redis 缓存,key 设计成session:{id},天然自带过期策略;
  • 多实例微服务:必须把上下文绑定 request-id,并明确传递到下游服务,否则肯定会出现上下文串台;
  • 多Agent编排:上下文往往要分为全局(任务目标、安全边界)和局部(每轮决策的思考链、工具返回值),局部上下文在每个决策完成后及时清理。

选型口诀一句话:承载层越轻量越好,上下文范围越小越少出问题。凡是能用参数传的,就不要设计成全局状态。

4. 踩坑实录:context-mode打开之后的三大典型事故

4.1 上下文膨胀与“记忆负担”

现象很典型:系统上线初期一切正常,跑了两周后响应变慢,请求费用直线上升,回答质量反而开始下降。

我排查这类问题时,第一步就是打印每次实际传给模型的消息规模,算增长曲线。举个量化例子:假设每条用户消息平均200字符,模型回复平均300字符,一次会话20轮,那就是1万字符左右。如果系统把7天内所有会话都持续携带,7天可能积累3500条以上的消息,字符数轻松突破几十万甚至上百万,明显超出模型的上下文窗口。

真正的问题在于:不是所有历史都值得记住。早期对话里那些没用的寒暄、中间态试错、与当前话题无关的用户抱怨,都在白白消耗窗口资源。

排查链路如下:

  1. 在入口处登记每次请求的input_token_count,按session聚合;
  2. 设定阈值(例如单次请求输入超过模型窗口的60%触发告警);
  3. 用二分法找到"引入哪段历史后回答质量明显下降";
  4. 把那段历史从原样记录改为摘要压缩。

修复策略我用了很多次,效果立竿见影:原始对话超过10轮后,保留最近10轮原文,更早的对话压缩成一段200字摘要。费用降了约三分之一,回答准确率反而有提。

4.2 上下文污染:不相关的旧信息影响新判断

第二个坑比膨胀更难察觉。有一次我的AI客服机器人在更换话题后,依然沿用上一话题的结论。用户问的是退款政策,系统却把"人工客服态度差"这类上个会话的抱怨内容带进了上下文,导致回复一本正经地确认了"您遇到的是态度问题"。

污染主要来自三处:

  • 会话历史里包含用户随口提的无关信息;
  • 工具调用返回值没清理就直接附在下一条消息后;
  • 多个用户共享了一个静态上下文对象。

排查污染问题的思路类似“控制变量法”。我第一次做时,先把 system prompt 打印出来检查是否稳定——不是它的问题。然后测试:去掉历史消息里关于上一个话题的两轮对话,结果模型对新问题的回答恢复正常。这就锁定了污染源的位置。

修复办法是双管齐下:在写入上下文之前做一个归类过滤,只保留与当前任务主题相关度高的信息;同时将上下文按taskId 分区,每次用户明确切换主题时,就把旧主题的上下文归档,而不是留到下一轮。

4.3 并发场景下的上下文串台

这是我认为后果最严重的坑。有一次负责一个内部工具,多个用户同时使用时,A用户看到的数据里混入了B用户的信息。调查时我们发现代码里把上下文对象定义成了模块级全局变量:

# 错误示范 current_context = {} def process(request): current_context["user_id"] = request.user_id work_with(current_context)

在单线程环境里这没问题,但一旦并发,线程A设置 user_id,线程B紧接着也设置,两个请求读到的 current_context 就是一个混合体。

根治方案只有一个:保证上下文对象在请求生命周期内创建,并且不要全局复用。示例如下:

# 正确示范 def handle_requests(request): ctx = Context() ctx.user_id = request.user_id ctx.request_id = request.id # 把ctx明确传给后续所有调用,绝不落回全局 result = do_task(ctx)

排查链路我建议这么走:

  1. 先复现:同时发起两个会话,观察是否互相干扰;
  2. 抓线程id和请求id,在代码里打印上下文操作日志;
  3. 找到写上下文的入口,检查是不是用了静态变量/全局单例;
  4. 把共享状态改为请求私有状态,重新压测。

这个坑之所以常见,是因为很多程序员写原型时"方便优先",全局变量随手就用,等并发量上来了再补课。上下文模式的底层原则就是:谁创建,谁传递,谁销毁。

5. 优化思路:让context-mode真正为你所用

5.1 剪枝与摘要降级

我在处理超大历史上下文时最常用的策略是分级降级:

  • 一级(原始层):最近一轮或两轮的原始对话完整保留,保证细节不丢;
  • 二级(摘要层):更早的历史用摘要记录,保留结论和关键参数;
  • 三级(关键词层):再早的历史只保留实体词、数字和结论标签,用于召回和统计,不参与直接推理。

举个例子,一个长达两小时的数据分析会话,到最后真正需要的上下文可能只是"用户关注错误集中在网关、已给出三张图表、需要进一步定位超时根因"。这个三级结构可以让上下文体积压缩80%以上,同时不丢关键线索。

5.2 分层上下文:全局层与任务层分离

我之前做客服系统时,把上下文拆成了两层。全局层包含用户名称、会员等级、售后政策版本、系统人设,这些信息每次请求都带。任务层则只保留本次会话的主题相关信息,比如"正在处理退货单#10086"。

这么设计的好处很直接:用户中途换话题时,我不用清空全部上下文,只需把任务层的会话历史重置,全局层继续保留。两个用户在相同主题下提问时,全局层之外的任务上下文彼此隔离。分层之后,上下文污染的概率大幅降低,调试时也更容易定位是哪一层出了问题。

5.3 好记性不如烂笔头:持久化与回放

做 context-mode 最容易被忽视的部分不是"怎么把信息带进当前请求",而是"如何把上下文沉淀下来供后续复现"。

我在工具里增加了持久化机制:每次请求结束后,把整个上下文的快照(包含消息、检索结果、中间统计)存为一份JSON文件或写入日志。这样出现线上问题时,可以直接用快照回放,重新跑一遍推理链路,不再需要依赖用户复述"我当时问了什么"。

具体落地方式:

def audit_context(session_id, ctx): path = f"context_archive/{session_id}.json" with open(path, "w") as f: json.dump({"ts": time.time(), "ctx": ctx}, f, ensure_ascii=False, indent=2)

这个习惯帮我省下过不少麻烦。有次功能更新后用户反馈问答质量下降,我不确定是新版提示词的问题还是上下文组装方式的问题。翻出历史快照,对比新旧两版在同一个上下文之下生成的回答,立刻定位到是 system prompt 里一句话的措辞导致模型行为偏移。没有上下文留档,这种对比是完全做不到的。

我自己在实战中的体会是:context-mode 并没有一个放之四海而皆准的默认配置,它更像是一种需要主动设计和持续监控的能力。开启它之前想清楚要共享什么、保留多久、如何隔离;开启之后老老实实做日志、做限流、做摘要。把这三个环节都处理好,这个模式才能从"高级功能"变成你工具箱里顺手的基础件。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进