Hello-Agents第9章上下文工程实战:解决多轮对话Context丢失与Token超限
发布时间:2026/9/26 15:03:47来源:尧图网络
1. 从终端里跑 Hello-Agents 第 9 章我踩到的第一个坑第一次在终端里跑 Hello-Agents 第 9 章的时候我盯着屏幕上的输出看了很久总觉得哪里不对劲。模型回复的内容看起来挺流畅但仔细一读就会发现它完全没有用到我前面几轮对话里给过的信息。换句话说第二轮发给 LLM 的内容里Context 丢了。这个问题不是 Hello-Agents 独有的任何在做 Agent 上下文工程的人都会遇到。你写了一个多轮对话的 Agent第一轮用户说了自己的名字、需求、偏好第二轮模型却像失忆一样重新问一遍。这不是模型笨而是你在构造第二轮请求的时候没有把第一轮的上下文正确地拼进去。Hello-Agents 是一个面向 Agent 开发的学习型项目第 9 章专门讲上下文工程Context Engineering。这一章的核心命题是如何把对话历史、工具调用结果、系统指令、外部知识等碎片信息组装成一份结构合理、Token 可控、语义连贯的上下文塞进 LLM 的输入窗口。听起来简单做起来全是细节。这篇文章适合两类人看一类是正在跟着 Hello-Agents 学 Agent 开发、卡在第 9 章上下文组装环节的人另一类是自己在写 LLM 应用、发现多轮对话总是“断片”的开发者。我会从终端实操的角度把上下文丢失的根因、上下文工程的完整链路、Token 预算的计算方式、以及我在调试过程中踩过的具体坑全部拆开讲清楚。提示本文所有操作均在本地终端完成涉及的命令和代码片段可以直接复现。如果你用的是 Windows建议在 WSL 2 的 Ubuntu 终端里操作避免路径和编码问题。2. 上下文丢失的根因不是模型的问题是你拼请求的方式有问题2.1 第二轮请求里到底该放什么很多人第一次写多轮对话的时候脑子里想的是“我把用户的新问题发给模型就行了”。于是第二轮请求的 messages 数组长这样messages [ {role: user, content: 那它支持哪些参数} ]模型收到这个请求它不知道“它”指的是什么不知道上一轮聊了什么自然只能瞎猜或者反问。正确的做法是把历史对话按顺序拼进去messages [ {role: system, content: 你是一个技术助手回答要简洁准确。}, {role: user, content: 介绍一下 Hello-Agents 这个项目。}, {role: assistant, content: Hello-Agents 是一个面向 Agent 开发的学习项目涵盖工具调用、记忆、规划、上下文工程等模块。}, {role: user, content: 那它支持哪些参数} ]这样模型才能理解“它”指的是 Hello-Agents。但问题来了对话轮次一多messages 数组会越来越长最终超出模型的上下文窗口限制。你会看到类似这样的报错api error: 400 this models maximum context length is 1048576 tokens.注意1048576 这个数字已经是百万级 Token 的窗口了但如果你不做任何裁剪几十轮对话加上工具调用结果照样能撑爆。所以上下文工程的核心不是“要不要放历史”而是“放多少、怎么放、什么时候丢”。2.2 上下文工程的三个层次我在 Hello-Agents 第 9 章的实践里把上下文工程拆成了三个层次来理解层次解决的问题典型手段拼接层历史消息怎么进 messages 数组滑动窗口、摘要压缩、角色标记预算层Token 总量怎么控制在窗口内Token 计数、动态裁剪、优先级排序语义层放进去的内容是否真的有用相关性过滤、工具结果精简、系统指令强化很多人只做了拼接层把历史一股脑塞进去结果要么超限报错要么模型被无关信息干扰回答质量反而下降。预算层和语义层才是区分“能跑”和“跑得好”的关键。2.3 为什么终端里调试上下文特别容易翻车在终端里跑 Agent 和在 Web 界面里跑有一个本质区别终端没有隐式的会话管理。Web 端的聊天产品通常会在服务端帮你维护 session你感知不到上下文是怎么拼的。但在终端里你得自己管理 messages 列表、自己决定什么时候截断、自己处理工具调用的返回值。我在终端里调试时遇到的第一个翻车场景是工具调用结果太长。比如我让 Agent 去读一个文件文件内容有几千行工具返回的 result 直接塞进 messages下一轮请求直接超限。第二个场景是系统指令被历史消息淹没。system message 放在最前面但后面跟了二十轮对话模型对 system 指令的注意力被稀释了。这两个问题的解法后面会详细讲。先把上下文组装的完整链路理清楚。3. 在终端里复现 Hello-Agents 第 9 章的上下文组装链路3.1 环境准备与项目结构确认我假设你已经 clone 了 Hello-Agents 的代码仓库并且装好了依赖。终端里先确认 Python 版本和关键库python --version pip list | grep -E openai|tiktoken|richtiktoken是用来做 Token 计数的rich是用来在终端里美化输出的。如果你还没装pip install tiktoken richHello-Agents 第 9 章相关的代码通常在chapter9/或context_engineering/目录下。进去之后先看 README 或者主入口文件确认运行方式。我当时的入口是python -m chapter9.main --interactive--interactive表示进入交互模式可以在终端里连续输入多轮对话。这个模式最适合观察上下文是怎么累积的。3.2 打印每一轮实际发送的 messages调试上下文问题第一步永远是把实际发给 LLM 的请求体打印出来。不要猜直接看。我在代码里加了一个 hookimport json def debug_messages(messages): print( * 60) print(f当前 messages 数量: {len(messages)}) for i, msg in enumerate(messages): content_preview msg[content][:80].replace(\n, ) print(f[{i}] role{msg[role]}, len{len(msg[content])}, preview{content_preview}) print( * 60) # 在调用 LLM 之前调用 debug_messages(messages)跑起来之后终端输出会告诉你每一轮 messages 数组里到底有几个元素、每个元素的角色和内容长度。我第一次跑的时候发现第二轮请求里只有一条 user message历史全丢了。原因是我在代码里每次循环都重新初始化了 messages 列表而不是复用上一轮的结果。这是一个非常典型的错误把 messages 定义在了循环内部。正确的做法是把它定义在循环外面每轮 append 新消息。3.3 工具调用结果如何进入上下文Hello-Agents 第 9 章涉及工具调用tool use。当模型决定调用一个工具时流程是这样的模型返回一个 tool_call包含工具名和参数。你的代码执行工具拿到结果。你把工具结果作为一条roletool的消息 append 到 messages。再次调用模型让它基于工具结果继续回答。这里有个细节工具结果的格式必须和模型的 tool_call id 对应。如果你用的是 OpenAI 兼容接口messages 里需要包含tool_call_id。漏掉这个字段模型会报 schema 错误llm request failed: provider rejected the request schema or tool payload.我在终端里遇到这个报错的时候排查了半天才发现是 tool_call_id 没传。补上之后就好了。3.4 一个最小可复现的上下文组装示例下面这段代码是我从 Hello-Agents 第 9 章里抽出来的最小逻辑去掉了业务细节保留了上下文组装的核心import tiktoken enc tiktoken.encoding_for_model(gpt-4) def count_tokens(messages): total 0 for msg in messages: total len(enc.encode(msg[content])) total 4 # 每条消息的角色标记开销 return total def build_context(history, new_user_input, system_prompt, max_tokens8000): messages [{role: system, content: system_prompt}] # 从最近的对话开始往前加直到接近预算上限 temp [] for msg in reversed(history): temp.insert(0, msg) candidate messages temp [{role: user, content: new_user_input}] if count_tokens(candidate) max_tokens: temp.pop(0) break messages.extend(temp) messages.append({role: user, content: new_user_input}) return messages这段代码的核心思路是从最近的对话往前回溯能放多少放多少放不下就丢最老的。这是滑动窗口策略的最简实现。实际项目中还会加上摘要压缩把丢掉的历史用一段摘要代替。4. Token 预算怎么算别等报错了才想起来数 Token4.1 为什么不能靠字符数估算很多人图省事用len(text)来估算 Token 数。英文场景下大概 4 个字符 1 个 Token中文场景下大概 1.5 到 2 个字符 1 个 Token。但这个比例不稳定代码、JSON、特殊符号的 Token 密度完全不一样。我实测过一段 JSON 格式的工具返回结果字符数 2000Token 数却到了 1800比例接近 1:1。如果用 4:1 估算会严重低估导致请求超限。所以结论很明确用 tiktoken 精确计数不要估算。虽然 tiktoken 编码本身有性能开销但对于单次请求来说几毫秒的耗时完全可以接受。4.2 上下文窗口的预算分配假设你用的模型上下文窗口是 128K Token你不能把 128K 全部用来放历史对话。你需要预留用途建议预留比例说明系统指令5%system prompt通常几百到几千 Token历史对话40%滑动窗口或摘要后的历史工具结果20%当前轮工具调用的返回内容模型输出25%max_tokens 设置留给模型生成缓冲10%防止计数误差导致超限这个分配不是固定的要根据实际场景调整。如果你的 Agent 主要是问答历史对话可以多留如果主要是工具调用工具结果要多留。4.3 动态裁剪的优先级策略当 Token 超预算时裁剪顺序应该是先裁最老的历史对话保留最近 N 轮。再裁工具结果中的冗余部分比如只保留关键字段丢掉原始 JSON 的嵌套结构。最后考虑压缩 system prompt但通常不建议动因为它是行为约束的核心。我在 Hello-Agents 里实现裁剪的时候用了一个简单的优先级队列每条消息带一个priority字段system 是 100最近的 user 是 90工具结果是 60老历史是 30。超预算时从低优先级的开始丢。4.4 实测不同裁剪策略对回答质量的影响我做了一组对比测试同一个多轮对话场景三种裁剪策略策略 A不裁剪直接超限报错。策略 B滑动窗口只保留最近 5 轮。策略 C滑动窗口 老历史摘要。结果很明显策略 A 跑不通策略 B 能跑但在第 6 轮之后模型开始忘记早期信息策略 C 表现最好摘要保留了关键信息模型在第 10 轮还能准确引用第 1 轮的内容。摘要的生成方式很简单就是在裁剪之前让模型把要丢掉的历史压缩成一段话summary_prompt 请用一段话总结以下对话的关键信息保留人名、需求、结论\n old_history_text这段摘要作为一条 system 或 assistant 消息放回 messages 的开头替代被裁掉的历史。5. 那些让我在终端前坐了两个小时的报错与排查过程5.1 报错一maximum context length 超限终端输出api error: 400 this models maximum context length is 1048576 tokens.第一反应是“我怎么可能用到百万 Token”。打印 messages 之后发现工具调用返回了一个巨大的 JSON里面包含了整个数据库的查询结果。这个结果被原封不动塞进了 messages。排查链路打印 messages 总 Token 数确认超限。逐条打印每条消息的 Token 数定位到最大的那条。发现是工具结果检查工具实现发现没有做结果截断。在工具返回处加上截断逻辑只保留前 2000 Token 的内容并附上“结果已截断”的提示。修复后的工具返回处理def truncate_tool_result(result, max_tokens2000): tokens enc.encode(result) if len(tokens) max_tokens: return result truncated enc.decode(tokens[:max_tokens]) return truncated \n...[结果已截断仅显示前部分内容]注意截断工具结果时一定要在末尾加提示否则模型会以为这就是完整结果可能给出错误结论。5.2 报错二tool_call_id 缺失导致 schema 拒绝终端输出llm request failed: provider rejected the request schema or tool payload.这个报错比超限更隐蔽因为它不告诉你具体哪个字段有问题。我的排查方式是把请求体完整打印成 JSON逐字段对照 API 文档。最后发现是 assistant 消息里的 tool_calls 和 tool 消息里的 tool_call_id 没有对应上。模型返回的 tool_call 有一个 id我在构造 tool 结果消息时没有把这个 id 带上。修复方式# 模型返回的 tool_call tool_call response.choices[0].message.tool_calls[0] # 执行工具后构造结果消息 tool_message { role: tool, tool_call_id: tool_call.id, # 这个字段必须有 content: tool_result } messages.append(tool_message)5.3 报错三中文乱码导致 Token 计数异常在 Windows 终端里跑的时候工具返回的中文内容出现了乱码导致 tiktoken 编码出来的 Token 数异常偏高。原因是终端编码不是 UTF-8。解决方式是在代码开头强制设置编码import sys import io sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8) sys.stderr io.TextIOWrapper(sys.stderr.buffer, encodingutf-8)或者在终端里设置环境变量export PYTHONIOENCODINGutf-8 export LANGen_US.UTF-8这个问题在 WSL 2 的 Ubuntu 终端里不会出现所以如果你在 Windows 原生终端里遇到乱码建议直接切到 WSL 2。5.4 排查上下文问题的通用套路经过这几次折腾我总结了一个排查上下文问题的固定流程打印请求体把实际发给 LLM 的 messages 完整打印出来。计数 Token用 tiktoken 逐条计算找出最大的几条。对照预期检查 messages 里是否包含了你以为包含的内容。检查角色system、user、assistant、tool 四种角色的顺序和配对是否正确。检查 id 对应tool_call 和 tool_call_id 是否一一对应。检查编码中文内容是否有乱码终端编码是否为 UTF-8。这个流程能覆盖 90% 以上的上下文相关问题。剩下的 10% 通常是模型本身的指令遵循问题那就需要调整 system prompt 了。6. 上下文工程里那些文档不会写的实操心得6.1 system prompt 要短而硬不要长而软我一开始写 system prompt 的时候恨不得把所有规则都写进去结果写了 2000 多 Token。实测下来模型对超长 system prompt 的遵循度反而下降因为关键指令被淹没在细节里。后来我改成核心规则不超过 5 条每条不超过 20 个字。比如你是技术助手。回答简洁。不确定时说不确定。不要编造 API。代码用 markdown 包裹。这样的 system prompt 只有几十个 Token但模型遵循度明显更高。细节规则可以放到工具描述或者后置的 user 消息里。6.2 历史对话里的 assistant 消息要保留但可以精简有些开发者为了省 Token把历史里的 assistant 消息全删了只保留 user 消息。这样做的问题是模型看不到自己之前的回答可能会前后矛盾。正确的做法是保留 assistant 消息但可以精简。比如把长篇回答压缩成要点# 原始 assistant 回答有 500 Token # 精简后 compressed 上一轮回答要点介绍了 Hello-Agents 的四个模块分别是工具调用、记忆、规划、上下文工程。这样既保留了语义又省了 Token。6.3 工具结果要结构化不要直接塞原始 JSON工具返回的原始 JSON 通常包含很多模型不需要的字段比如 id、timestamp、status_code。这些字段占 Token 但不提供语义价值。我的做法是在工具层做一次转换只把模型需要的字段提取出来def format_tool_result(raw_result): # raw_result 是原始 JSON essential { title: raw_result.get(title), summary: raw_result.get(summary), key_points: raw_result.get(key_points, [])[:5] } return json.dumps(essential, ensure_asciiFalse)这样能把工具结果的 Token 数压缩 60% 以上同时不损失关键信息。6.4 多轮对话的轮次不是越多越好我做过测试同一个任务给模型 3 轮历史 vs 10 轮历史回答质量并没有显著提升但 Token 消耗增加了 3 倍。原因是大部分历史信息对当前问题无关。所以我的建议是默认保留最近 3 到 5 轮更早的历史用摘要代替。如果当前问题和早期历史强相关比如用户在第一轮定义了变量名后面一直在用那就在摘要里保留这些关键定义。6.5 在终端里加一个 /context 命令查看当前上下文调试的时候频繁改代码打印 messages 很麻烦。我在交互模式里加了一个/context命令输入之后直接显示当前 messages 的摘要信息if user_input /context: debug_messages(messages) continue这样不用退出程序就能随时查看上下文状态。类似地还可以加/tokens命令显示总 Token 数/clear命令清空历史重新开始。7. 从 Hello-Agents 第 9 章延伸出去上下文工程的通用方法论7.1 上下文工程和提示词工程的区别很多人把这两个概念混在一起。我的理解是提示词工程关注的是“怎么问”上下文工程关注的是“给模型看什么”。提示词工程研究的是 system prompt 怎么写、few-shot 示例怎么选、输出格式怎么约束。上下文工程研究的是多轮对话历史怎么管理、工具结果怎么裁剪、外部知识怎么注入、Token 预算怎么分配。两者有重叠但侧重点不同。Hello-Agents 第 9 章把上下文工程单独拎出来讲就是因为在实际 Agent 开发中上下文管理的工作量和复杂度往往超过提示词本身。7.2 上下文工程的核心原则我在实践里总结了四条原则原则一上下文是有限的不是免费的。每多放一个 Token就少一个 Token 留给模型输出。要有预算意识。原则二相关性比完整性更重要。与其放 10 条可能相关的历史不如放 3 条确定相关的。无关信息会干扰模型判断。原则三结构比内容更重要。同样一段信息用清晰的角色和格式组织比一股脑塞进去效果好得多。system、user、assistant、tool 四种角色的边界要清晰。原则四可观测性是一切的前提。如果你不知道实际发出去的请求长什么样你就无法优化它。打印请求体、计数 Token、记录每轮上下文变化这些调试手段是必须的。7.3 不同场景下的上下文策略选择场景推荐策略原因短对话问答全量保留历史轮次少Token 压力小长对话客服滑动窗口 摘要轮次多需要控制 Token工具调用密集工具结果精简 优先级裁剪工具结果占 Token 大头知识库问答RAG 检索 相关性过滤外部知识按需注入代码生成保留最近代码上下文 文件结构摘要代码 Token 密度高这张表不是绝对的实际项目中往往需要组合使用。比如一个代码助手 Agent既需要保留对话历史又需要注入相关代码文件还需要处理工具调用结果三种策略要叠加。7.4 上下文工程的未来方向从我在 Hello-Agents 第 9 章的实践来看上下文工程正在从“手工规则”向“模型自主管理”演进。现在已经有一些方案让模型自己决定哪些历史重要、哪些可以丢。比如让模型在每轮结束时输出一个“记忆摘要”下一轮直接用这个摘要作为上下文。这种方式的好处是减少了人工规则坏处是增加了一次模型调用而且摘要质量依赖模型能力。我的建议是在 Token 预算充足、对延迟不敏感的场景下可以尝试模型自主摘要在对延迟敏感的场景下还是用规则裁剪更稳。8. 我在终端里跑通第 9 章之后的几点体会跑通 Hello-Agents 第 9 章之后我最大的感受是上下文工程不是一个“写完就不用管”的模块而是一个需要持续观测和调优的过程。你永远不知道模型下一轮会收到什么奇怪的上下文组合直到你把它打印出来。我现在养成了一个习惯任何 LLM 应用上线之前先在终端里跑 20 轮对话每轮都打印 messages 和 Token 数。这个过程能暴露 80% 的上下文问题。剩下的 20%通常要等到真实用户用出奇怪的问题才会发现。另外一点体会是不要迷信大窗口。百万 Token 的窗口听起来很爽但模型对超长上下文的注意力是衰减的。放在中间位置的信息模型很可能忽略。所以与其把所有东西都塞进去不如精选最相关的几条放在开头和结尾这些注意力高的位置。最后分享一个我在终端里调试时的小技巧把每轮对话的 messages 保存成 JSON 文件按时间戳命名。这样出问题的时候可以回溯对比正常轮次和异常轮次的上下文差异。这个习惯帮我定位过好几次“模型突然变傻”的问题最后发现都是上下文里混入了异常的工具结果或者重复的历史消息。上下文工程这件事说到底就是一句话让模型在正确的时间看到正确的信息。听起来简单做起来全是细节。但只要你把可观测性做好把 Token 预算算清楚把裁剪策略定明白剩下的就是不断迭代了。
网站建设高端定制企业官网