1. 从一次终端报错说起为什么第二轮对话丢了 Context那天晚上我在终端里跑 Hello-Agents 第 9 章的示例代码第一轮对话一切正常模型回答得头头是道。到了第二轮我追加了一个追问结果模型的回复完全像是没看过第一轮的内容——答非所问上下文断裂得干干净净。我盯着终端里那两段 JSON 请求体看了半天才反应过来问题出在哪第二轮发给 LLM 的消息列表里根本没有把第一轮的对话历史带进去。这不是什么高深的 bug恰恰相反它太基础了基础到很多人第一次写 Agent 循环时都会踩。但正是这个低级错误让我决定把 Hello-Agents 第 9 章关于上下文工程的内容重新梳理一遍。因为**上下文工程Context Engineering**这件事表面上看是把历史消息拼进去这么简单实际上它牵扯到消息结构设计、Token 预算分配、上下文压缩策略、状态管理等一系列工程问题。你稍微往深了想就会发现这里面全是坑。这篇文章适合谁看如果你正在用任何 LLM 框架写多轮对话、写 Agent、写带记忆的问答系统或者你只是好奇为什么我的模型聊两句就失忆那这篇内容你应该能读进去。我会从这次终端翻车现场讲起把上下文工程的核心逻辑、实操细节、常见坑位一个个拆开说。全程用大白话配可复现的代码和参数不整虚的。先说结论上下文工程不是把聊天记录塞给模型这么粗暴它是一套关于在有限 Token 预算内如何让模型看到最该看的信息的取舍艺术。第 9 章讲的就是这套取舍的方法论而我的终端翻车恰好是没做好最基础的那一层。2. 上下文工程到底在解决什么问题2.1 从提示词工程到上下文工程的认知升级早两年大家聊的都是提示词工程Prompt Engineering核心是怎么把这一句话写好。你调措辞、加角色设定、加 few-shot 示例本质上是在优化单次输入的质量。但当你开始做多轮对话或者 Agent 的时候你会发现单次提示词写得好根本不够用——因为模型每一轮看到的不是一句话而是一整个消息序列。这就是上下文工程要处理的东西。它管的不是这一句怎么写而是这一轮该给模型看哪些内容、按什么顺序、占多少预算。打个比方提示词工程像是你写一封邮件上下文工程像是你管理整个邮件往来线程——哪些回复要引用、哪些附件要带上、哪些历史要折叠起来。Hello-Agents 第 9 章把这个区别讲得很清楚。它把上下文分成几个层次系统指令System、对话历史History、检索到的外部知识Retrieved Context、工具调用结果Tool Output、当前用户输入Current Input。这五块内容共同竞争同一个 Token 窗口而上下文工程的核心工作就是决定每一块分多少、怎么裁剪、怎么压缩。2.2 Token 窗口是硬约束不是软建议很多人对 Token 窗口的理解停留在超了会报错。确实会报错比如你可能会遇到类似maximum context length is 1048576 tokens这样的提示但那只是最粗暴的一种失败方式。更隐蔽的问题是即使你没超窗口上下文塞得太满也会让模型变笨。这不是玄学。模型在长上下文里的注意力是会被稀释的业界管这叫lost in the middle——放在中间位置的信息最容易被忽略。所以上下文工程不只是别超限更是别浪费。你把一堆无关的历史对话塞进去不仅占了预算还干扰了模型对关键信息的注意力。我在实际项目里做过对比同一个问答任务把 20 轮无关历史全塞进去和只保留最近 3 轮加一条摘要后者的回答准确率明显更高。原因很简单模型的有效注意力被集中在了真正相关的内容上。2.3 上下文工程和 RAG、记忆系统的关系这里要澄清一个容易混淆的点。RAG检索增强生成解决的是从外部知识库捞相关内容记忆系统解决的是跨会话记住用户偏好而上下文工程是那个把它们组织起来、塞进 Token 窗口的总调度。三者是协作关系不是替代关系。RAG 捞回来的文档片段、记忆系统取出来的用户画像、当前对话的历史最后都要经过上下文工程的编排才能变成一份模型能消化的输入。Hello-Agents 第 9 章之所以单独用一章讲这个就是因为它是承上启下的关键环节——上游的检索和记忆做得再好编排没做好一样白搭。3. 消息结构设计第一轮和第二轮到底差在哪3.1 标准消息列表长什么样先把我终端里那个翻车的请求体还原一下。第一轮大概是这样的{ model: your-model, messages: [ {role: system, content: 你是一个乐于助人的助手。}, {role: user, content: 帮我解释一下什么是上下文工程。} ] }模型正常回答了。然后第二轮我追加了一个问题但代码里是这么写的{ model: your-model, messages: [ {role: system, content: 你是一个乐于助人的助手。}, {role: user, content: 那它和提示词工程有什么区别} ] }看出来了吗第一轮的 user 消息和 assistant 回复全没了。模型看到的只有系统指令加一个孤零零的新问题它当然不知道它指的是什么。这就是标题里说的第二轮发给 LLM 的内容里没有 Context。正确的做法是把历史累积起来{ model: your-model, messages: [ {role: system, content: 你是一个乐于助人的助手。}, {role: user, content: 帮我解释一下什么是上下文工程。}, {role: assistant, content: 上下文工程是指...}, {role: user, content: 那它和提示词工程有什么区别} ] }3.2 消息角色不是随便填的消息列表里的role字段有讲究。常见的有system、user、assistant、tool四种。它们的语义和优先级不一样角色作用注意事项system设定全局行为、人设、约束通常放最前部分模型对它的权重更高user用户输入多轮时要按时间顺序排列assistant模型历史回复必须保留否则模型不知道自己说过什么tool工具调用返回结果要和对应的 tool_call 配对不能孤立出现我见过有人为了省 Token把 assistant 的历史回复全删了只留 user 消息。结果模型完全接不上话因为它不知道上一轮自己承诺了什么。assistant 消息是上下文的一部分不是可有可无的装饰。3.3 多轮拼接的两种常见模式实操里拼接历史有两种主流模式各有取舍全量拼接把从第一轮开始的所有消息都带上。优点是信息完整缺点是 Token 消耗随轮数线性增长聊到几十轮必然爆窗口。滑动窗口只保留最近 N 轮。优点是 Token 可控缺点是早期的重要信息会丢失。Hello-Agents 第 9 章推荐的是混合策略滑动窗口保底再叠加一层摘要。具体做法是——保留最近 K 轮完整对话把更早的历史压缩成一段摘要作为一条 system 或 user 消息插在最前面。这样既控制了 Token又不会把早期信息彻底丢掉。我自己的经验是 K 取 3 到 5 比较合适。太少容易断片太多又浪费预算。当然这取决于你的单轮消息长度如果每轮都很长K 就得调小。4. Token 预算分配把有限的窗口花在刀刃上4.1 先算清楚你的预算账上下文工程的第一步不是写代码是算账。你得知道你的 Token 窗口有多大然后给每一块内容分配配额。假设你的模型窗口是 128K Token一个合理的分配可能是这样内容块建议占比说明系统指令5%固定开销尽量精简检索知识30%RAG 场景下的大头对话历史40%多轮对话的核心工具结果15%有工具调用时才有当前输入 预留输出10%给模型回复留空间这个比例不是死的要按场景调。纯聊天场景可以把历史提到 60%RAG 场景则要给检索内容更多空间。关键是心里要有这本账而不是无脑往里塞。4.2 怎么估算 Token 数不同模型的 Tokenizer 不一样中文和英文的切分粒度也不同。粗略的经验值是英文大约 4 个字符 1 个 Token中文大约 1 到 2 个字符 1 个 Token。但这个误差很大正式项目里一定要用对应模型的 tokenizer 精确计算。Python 里可以用tiktoken之类的库来算import tiktoken enc tiktoken.get_encoding(cl100k_base) text 这是一段用来估算 Token 的中文内容。 tokens enc.encode(text) print(len(tokens))我踩过的坑是只算了输入没算输出。有一次我把输入塞到窗口的 95%结果模型刚开始回复就被截断了。后来我固定预留 15% 到 20% 给输出再没出过这个问题。4.3 超预算时的裁剪优先级当内容超预算时得有明确的裁剪顺序。我的做法是按这个优先级从低到高砍先砍最老的对话历史保留摘要再砍相关性最低的检索片段然后精简工具返回结果只留关键字段最后才动系统指令这个尽量别动注意裁剪历史时一定要成对处理 user 和 assistant 消息。只删 user 不删 assistant或者反过来会让消息序列语义错乱模型容易产生幻觉。Hello-Agents 第 9 章特别强调了一点裁剪不是简单截断而是有损压缩。你要保证压缩后的内容仍然能支撑当前这轮对话的推理而不是机械地按 Token 数切。这一点后面讲压缩策略时还会展开。5. 上下文压缩让历史信息瘦身但不失忆5.1 摘要压缩的基本做法当对话轮数多了最直接的办法就是摘要压缩。把早期的一批对话交给模型让它生成一段简短摘要然后用这段摘要替换掉原始的多轮消息。具体流程是这样的当历史 Token 超过阈值比如预算的 70%时触发压缩取出最老的一批消息比如最早的 10 轮调用模型生成摘要提示词类似请用 200 字总结以下对话的关键信息保留事实、结论和未完成的任务用摘要替换原始消息作为一条 system 消息插回列表这里有个细节摘要要保留什么、丢弃什么直接决定压缩质量。我一般会在提示词里明确要求保留用户的核心诉求、已达成的结论、待办事项、关键实体人名、数字、专有名词。而寒暄、重复确认、无关闲聊可以大胆丢。5.2 分层记忆把信息按重要性分级比单纯摘要更进阶的做法是分层记忆。Hello-Agents 第 9 章提到了这个思路把上下文分成短期记忆、工作记忆、长期记忆三层。短期记忆最近几轮完整对话原样保留工作记忆当前任务的中间状态、变量、进度结构化存储长期记忆跨会话的用户偏好、历史结论存到外部存储按需检索这样每一轮组装上下文时短期记忆直接拼工作记忆按当前任务取长期记忆通过检索捞。三层各司其职Token 利用率比一锅炖高得多。我在一个客服 Agent 项目里用过这个结构效果很明显用户问我上次那个订单怎么样了系统能从长期记忆里捞出用户 ID 和历史订单从工作记忆里拿到当前会话的订单号再拼上最近几轮对话模型就能准确回答而不是一脸茫然。5.3 压缩的时机和阈值怎么定压缩太频繁会丢信息太晚又会爆窗口。我的经验是设两个阈值软阈值70%达到就开始准备压缩但不强制硬阈值90%达到必须压缩否则拒绝新输入软阈值给了一个缓冲让你可以在对话自然停顿的时候压缩而不是在关键追问时被迫中断。硬阈值是安全网防止真的爆掉。提示压缩本身也要消耗 Token 和一次模型调用。如果你的应用对延迟敏感压缩最好异步做别卡在用户等待的主链路上。6. 实操复现在终端里搭一个带上下文的对话循环6.1 最小可运行骨架说了这么多原理来点能直接抄的。下面是一个最小可运行的上下文管理骨架用 Python 写逻辑清晰你可以直接套到自己的框架里import tiktoken class ContextManager: def __init__(self, max_tokens8000, reserve_output1500): self.max_tokens max_tokens self.reserve_output reserve_output self.history [] self.summary self.enc tiktoken.get_encoding(cl100k_base) def count(self, text): return len(self.enc.encode(text)) def total_tokens(self): total self.count(self.summary) for msg in self.history: total self.count(msg[content]) 4 return total def add(self, role, content): self.history.append({role: role, content: content}) self._maybe_compress() def _maybe_compress(self): budget self.max_tokens - self.reserve_output if self.total_tokens() budget * 0.9: self._compress() def _compress(self): # 保留最近 4 轮其余压缩成摘要 keep 8 # 4 轮 8 条消息 old self.history[:-keep] recent self.history[-keep:] if not old: return old_text \n.join(f{m[role]}: {m[content]} for m in old) # 这里调用你的 LLM 生成摘要伪代码 new_summary call_llm( 请用200字总结以下对话保留事实、结论和待办\n old_text ) self.summary (self.summary \n new_summary).strip() self.history recent def build_messages(self, system_prompt, user_input): messages [{role: system, content: system_prompt}] if self.summary: messages.append({role: system, content: 历史摘要 self.summary}) messages.extend(self.history) messages.append({role: user, content: user_input}) return messages这个骨架做了三件事累积历史、监控 Token、超限压缩。你把它接到任何 LLM API 上都能跑。6.2 关键参数怎么调上面代码里有几个参数值得单独说max_tokens8000这是你模型的实际窗口按需改。别填成模型上限留点余量。reserve_output1500给模型回复预留的空间。回复越长这个值越大。keep8压缩时保留的最近消息数。8 条等于 4 轮问答一般够用。压缩触发线0.9硬阈值。你也可以加个 0.7 的软阈值做提前准备。我实测下来reserve_output设成窗口的 15% 到 20% 比较稳。设太小模型回复会被截断设太大又浪费了输入空间。6.3 终端调试的实用技巧在终端里调上下文问题光看代码不够得看实际发出去的请求体。我的习惯是在每次调用前把 messages 打印出来或者写进日志文件import json with open(debug_context.jsonl, a, encodingutf-8) as f: f.write(json.dumps(messages, ensure_asciiFalse) \n)这样你就能一眼看出第二轮到底带没带历史。我那次翻车就是因为没打日志盯着代码看了半天才发现是拼接逻辑写错了。另外终端里看长 JSON 很痛苦可以用jq格式化cat debug_context.jsonl | jq .或者只看消息角色序列快速判断结构对不对cat debug_context.jsonl | jq -r .[].role | tr \n 输出类似system user assistant user一眼就知道历史有没有带上。7. 常见问题与排查速查表7.1 模型失忆的几种典型表现多轮对话出问题症状往往很相似但根因不同。我整理了一张速查表症状可能原因排查方向第二轮完全不记得第一轮历史没拼进 messages检查拼接逻辑打印请求体记得最近几轮忘了更早的滑动窗口太小调大 keep 值或加摘要回答开始重复、绕圈上下文塞太满注意力稀释检查 Token 占用做压缩引用错误的事实摘要压缩丢了关键信息优化摘要提示词保留实体工具调用结果被忽略tool 消息没配对或位置错检查 tool_call 与 tool 结果配对这张表我贴在工位上出问题先对号入座能省不少排查时间。7.2 几个我踩过的坑坑一只拼 user 不拼 assistant。前面说过这会让模型不知道自己说过什么。修复很简单但第一次写循环时特别容易漏。坑二压缩时把 system 消息也压进去了。系统指令是全局约束不该被摘要替换。压缩时一定要把 system 消息排除在外。坑三Token 估算用错 tokenizer。不同模型的切分方式不同用错 tokenizer 会导致估算偏差要么浪费空间要么爆窗口。一定要用目标模型对应的 tokenizer。坑四压缩后没更新总 Token 计数。压缩完忘了重算导致后续判断失准。压缩后一定要重新统计。7.3 上下文工程的几条经验法则最后分享几条我总结的经验法则都是实战里验证过的系统指令永远置顶且不参与压缩。它是全局约束动了会出大问题。历史消息成对保留。user 和 assistant 要一起进退别拆散。摘要里保留实体和数字。人名、订单号、金额这类信息丢了模型就会编。给输出留足空间。输入塞太满回复必被截。压缩异步做。别让用户等你的摘要生成。日志要打全。出问题时请求体日志是唯一的真相来源。Hello-Agents 第 9 章讲上下文工程核心就一句话模型看到什么决定了它能回答什么。你给它的上下文越精准、越相关、越不浪费它的表现就越好。反过来上下文管理一塌糊涂再强的模型也救不了。我那次终端翻车说到底就是把最基础的一层——历史拼接——给漏了。补上之后第二轮对话立刻就正常了。但这件事提醒我上下文工程里没有太基础所以不用管的环节每一层都得扎实。后面我又陆续加了摘要压缩、分层记忆、Token 预算监控整套跑下来多轮对话的稳定性和准确率都上了一个台阶。如果你也在做类似的东西建议从最小骨架开始先把历史拼对再一步步往上加压缩和分层别一上来就搞复杂架构那样出了问题你都不知道是哪一层坏的。