中级(一):多轮对话与上下文管理

19 阅读19分钟

一、从初级到中级,卡在哪一步

上一章我们交付了 main_rag.py:一个能基于私有知识库回答问题、会引用来源、会幂等更新索引的最小 RAG Agent。

跑起来之后你大概率会发现一个很尴尬的现象:

你:张三是哪个部门的?

Agent:张三是市场部的(基于 xxx.md)。

你:他的上级是谁?

Agent:请问"他"是谁?

它每一句都对,但它不记得上一句。

这就是从初级走向中级的第一道分水岭。

我在开篇里画过那张分层表:

档位对应 Android代表能力
初级能写页面、会调 SDK、懂四大组件会接 LLM、会写 Prompt、能做基础 RAG、能搭 Demo 级 Agent
中级会架构、性能优化、组件化多轮上下文、流式、Tool Use、服务化、检索优化
高级Framework、SDK、跨端架构、主导项目Agent 架构、工作流编排、评测体系、成本治理、AI Native 产品落地

注意中级第一项: 上下文。这就是本篇要讲的事。

本章结束时你应该做到三件事:

  1. 能讲清楚"LLM 的多轮到底是怎么实现的";
  2. 能把 main_rag.py 升级成会记事的 main_memory.py
  3. 能用三种策略(全量 / 滑窗 / 摘要)控制上下文,并讲出它们各自的代价。

配套 Demo:main_memory.py

二、中级 AI Agent 工程师在真实公司里做什么(JD 拆解)

先把结论放在前面。这一句我不是自己编的,是 JD 原文抄的:

"具备良好的问题拆解能力,能在不确定性较高的 AI 场景中,将业务需求转化为稳定的工程方案。 "

——某消费级智能硬件大厂 · AI 应用开发工程师(智能体)岗位要求

把这句话里的术语拿掉,翻译成大白话:

中级 = 在 AI 这种"东西老变、模型会错、边界不清"的不确定性里,依然能交出稳定工程方案的人。

下面三份 JD,来自 2025 年 Boss 直聘深圳区公开岗位,去除公司名和 HR 信息。看真岗位、看真要求。

1. 中级 JD 画像

样本岗位薪资经验方向
JD-ⒶAI 应用开发工程师(智能体)· 某消费级智能硬件大厂20–40K3–5 年 / 本科后端 · 企业 AI 平台 · Agent / MCP / Skill
JD-Ⓑ高级前端工程师(Flutter / AI 智能体)· 某可穿戴硬件公司未披露Flutter 2 年+ + AI 任一项客户端 · AI Native App · 可穿戴
JD-Ⓒ (对照)AI 应用开发工程师(某大屏智能终端厂商)未披露5 年+端侧 AI / 嵌入式(平行赛道,作对照)

两份主样本(Ⓐ 后端 + Ⓑ 前端)一起看,画面就完整了:

  • Ⓐ 那份是中级 Agent 工程师的教科书级 JD——几乎把本系列中级 1~4 章的目录原样抄了一遍;
  • Ⓑ 那份开篇愿景(智能戒指 / 手环 / 手表 / 可穿戴 AI Native)的真实落地 JD——它把"流式响应、上下文管理、多轮对话、Tool Use"打包写进了任职要求。

Ⓒ 大屏那份是一条平行赛道——端侧 AI / 模型量化 / 嵌入式,和本系列主线不是一条路。不是不好,是不同工种。读完本系列你会自带判断力知道自己是不是想走那条。

2. 关键词频次:中级比初级多出来的是什么

关键词JD-ⒶJD-Ⓑ中级命中初级命中*
Function Calling / Tool Use / MCP21月3日
流式响应(SSE / streaming)10/3
多轮对话 / 上下文管理 / Memory21月3日
Agent 框架(LangChain / LangGraph / AutoGen)10/3
CoT / Few-Shot / Prompt 优化10/3
RAG + 向量数据库✅(认知)23月3日
业务需求→工程方案(不确定性转化)22月3日
对话式 + 非对话式交互设计10/3
服务化 / 架构设计21月3日

*初级命中列复用初级 JD 拆解节的三份样本数据,方便对照。

三条从数据里读出来的结论:

① 中级比初级多出来的 5 个关键词—— Function Calling / Tool Use / MCP流式响应多轮对话与 MemoryAgent 框架CoT / Few-Shot这就是本系列中级 1~4 章的章节目录。 不是我根据题材编的,是招聘市场反向推导出来的。

② 中级岗真正的差异不在"会的东西更多",而在"要求更明确"。

  • 初级可以"Agent 和 RAG 做过一类";
  • 中级必须"Function Calling 原理讲清楚、能设计供 AI 调用的服务、能做工具链"。

③ 中级阶段开始出现"前端中级 / 后端中级"分流。 JD-Ⓐ 是后端方向(Python / 企业 AI 平台),JD-Ⓑ 是客户端方向(Flutter / AI Native App)。 这两条路不互斥,但会在中级阶段明显分叉——你要想一下自己往哪边走。

3. 四段原文金句

① "调 API" vs "做 API" 的分水岭

"深刻理解 LLM 的 Function Calling 原理,具备 MCP 与 Skill 开发能力,能设计优雅的服务供 AI 调用。"

——JD-Ⓐ 岗位要求 4(2)

解读:初级"调 API"是你接 LLM 的 API;中级"做 API"是你设计让 LLM 来调用你的服务。一个箭头反过来——就这一个反转,把初级和中级彻底区分开。这正是中级(三)Function Calling 要讲透的那件事。

② 中级的 Prompt 已经不是"让它好好说话"

"能编写高复杂度的提示词来精准控制模型行为,通过 思维链(CoT)或少样本(Few-Shot) 优化工具调用的准确率。"

——JD-Ⓐ 岗位要求 4(1)

解读:中级 Prompt 要解决的是**"工具调用准确率"这种可量化的工程目标**,不是风格和口吻。Prompt 变成了工程手段,不再是作文技巧。

③ 一句话总结中级(一)(二)(三)

"实现与大模型的交互机制(流式响应、上下文管理、多轮对话、工具调用等)。"

——JD-Ⓑ 主要职责第 4 条

解读:这一条几乎什么都不用补,本系列中级(一)多轮、(二)流式、(三)Function Calling 就是为了让你能直接打勾这四个技能

④ 最值钱的那一段:中级的"业务翻译能力"

"相比传统设备,我们更关注的是:如何将持续产生的生理与行为数据,转化为对用户真正有价值的反馈与行动建议。 App 不只是数据展示工具,而是一个能够理解用户状态、参与决策的 AI Agent。 让 感知 → 理解 → 行动 形成闭环。"

——JD-Ⓑ 职位描述

解读:这段在告诉你——

中级 AI Agent 工程师的真正价值,不是把 API 和 UI 拼起来,是把"数据 → 状态 → 产品表达"这三步打通。

这恰好是算法工程师做不了、纯前端做不了、纯后端也做不了的那块——是原生开发者最该占领的阵地

4. 把这两份 JD 翻译成"中级日常在做什么"

抽掉所有话术和修辞,中级日常就是下面 6 件事:

#日常在做的事对应章节 / Demo
1设计并维护多轮对话的上下文(窗口 + 摘要 + RAG 注入)中级(一)· main_memory.py
2流式响应落地(服务端 + 客户端 + 中断/错误处理)中级(二)· main_stream.py
3设计并对接 Function Calling / Tool / MCP / Skill中级(三)
4检索系统优化(Rerank / 混合检索 / Query 改写)中级(四)
5把上面的能力封装成服务,供 App / 前端 / 其他团队调用中级(五)
6稳定性治理:超时、重试、兜底、成本、灰度中级(六)

这张表就是中级章节目录,也是你下一次求职面试的自我介绍底稿

5. 薪资 × 经验参考(深圳,2025)

经验档薪资区间年薪(按 13 薪)对应样本
2 年+(中级前端起步)20–35K26–46 万JD-Ⓑ 推测
3–5 年(典型中级)20–40K × 13 薪26–52 万JD-Ⓐ 实锤
5 年+(中级上限 / 高级入口)35–55K × 13~14 薪45–80 万另议

对比初级档的 20–50 万区间,你会发现一个事实: "薪资上限相近,下限不同"

  • 这意味着:卡在中级门槛的原生开发者,转过去后薪资几乎没有短期下降风险

  • 而一旦补齐中级(一~六)的能力栈,长期的薪资天花板会明显抬升(高级档 45–80 万是原生单方向岗位很难够到的数字)。

6. 锋利收尾

把这两份 JD 的要求全抄一遍,你会看到这样一张画像:

  • 能讲清楚 LLM 底层协议(messages / role / token / 上下文窗口)
  • 会做多轮对话、会管上下文、会压摘要、会控 token 预算
  • 会做流式、能处理中断和错误
  • 会做 Function Calling,能设计供 AI 调用的服务
  • 会做服务化封装,能和前端 / 客户端对接
  • 能在不确定性里把业务需求翻译成稳定的工程方案
  • 能跨算法 / 硬件 / 产品团队推进

看起来很多——但如果你盯着本系列中级(一~六)的目录再看一遍这张画像,几乎是一一对应

初级你可以"做过就算",中级你必须"能讲清楚、能设计、能稳定落地"。这三个"能",就是本系列中级 6 章要帮你补上的全部内容。

下一节把中级的完整能力清单列出来,然后从多轮对话开始动手。

三、先放中级的能力清单

下面六章会依次交付这张表里的六个能力。本篇只负责第一项。

#章节代表能力对应 Demo
1多轮对话与上下文管理messages 维护、滑窗、摘要、token 预算main_memory.py
2流式响应(SSE/Streaming)首 token 延迟、边生成边渲染main_stream.py
3Tool Use / Function Calling从"只会说话"到"会做事"main_tools.py
4检索优化Rerank、混合检索、Query 改写main_retrieval.py
5服务化封装FastAPI 封装、会话协议、前后端分离server/
6工程化治理超时、重试、兜底、日志、成本统计main_robust.py

中级不是炫技,是把初级的"能跑"变成"能上线"。

这里插一句:很多人以为中级就是堆框架,其实刚好相反。中级是把初级那 50 行扩成 300 行时,依然能讲清楚每一行为什么存在

四、多轮对话的本质:你一直在"拼 messages"

这一节是本章最值钱的一节。如果你只记得一句话,就记下面这句:

LLM 本身是无状态的。所有"多轮",都是客户端在反复拼一个更长的 messages 数组发过去。

回忆上一章讲过的三种角色:

  • system:角色设定、行为约束
  • user:用户输入
  • assistant:模型历史回答

单轮的时候你发的是:

[    {"role": "system",   "content": SYSTEM_PROMPT},    {"role": "user",     "content": "张三是哪个部门的?"},]

多轮的时候你要发的是:

[    {"role": "system",    "content": SYSTEM_PROMPT},    {"role": "user",      "content": "张三是哪个部门的?"},    {"role": "assistant", "content": "张三是市场部的。"},    {"role": "user",      "content": "他的上级是谁?"},]

两次请求之间,服务端什么都不记得。是你自己在客户端把"上一轮的 user + assistant"重新塞进了 messages

理解了这一点,中级的所有上下文玩法都不再神秘,它们全部在回答同一个问题:

历史越拼越长,迟早会撑爆上下文窗口。你要怎么在"记得住"和"装得下"之间做权衡?

五、三种上下文策略,你全都得会

策略 A:全量历史(最朴素)

每一轮都把完整历史发过去。

  • 优点:零丢失,实现最简单,一个 list 就搞定。

  • 缺点:上下文是线性增长的,十几轮以后 token 成本、延迟、窗口都会失控。

  • 适用:3~5 轮以内的短对话,或者写 Demo 做对照。

策略 B:滑动窗口(最常用)

只保留最近 N 轮,更早的直接扔掉。

messages = [system] + history[-2 * N :] + [current_user]
  • 优点:实现只有一行,成本恒定,延迟稳定。
  • 缺点:老信息会被"遗忘",指代消解会翻车("张三" 刚说完就忘了)。
  • 适用:工具类、客服类、每轮话题独立性强的场景。

这是生产里最常见的一档,80% 的对话型 Agent 第一版都长这个样子

策略 C:摘要压缩(长对话必备)

当历史 token 超过预算时,把"更早的历史"交给 LLM 自己压成一段摘要,塞回去当 system。

[system]  角色设定
[system]  更早对话摘要(自动生成,不断被刷新)
[user]    最近第 N-5 轮
[assistant] ...
...
[user]    当前问题
  • 优点:长对话里关键事实不丢(用户偏好、已确认的结论、未完成的待办)。

  • 缺点:摘要本身要花一次 LLM 调用,有延迟和费用;摘得不好会"看似记得,其实全错"。

  • 适用:长伴随型对话、助手类产品、多步骤任务。

真正的生产答案:B + C 组合

没有哪个产品是纯 A / 纯 B / 纯 C。

工程上的标准做法是:

  1. 硬窗口:最近 N 轮永远完整保留(对应策略 B);
  2. 软预算:整个 messages 的 token 数超过阈值时,把"N 轮以外的"折叠成摘要(对应策略 C);
  3. 系统提示 永远在最前、永远不动。

记住这个三段式:

system + (可选的摘要) + 最近 N 轮 + 当前 user

main_memory.py 里的 ConversationMemory 就是这个三段式的落地。

六、Token 预算:工程师必须会算的一笔账

你不会在 Android 里把所有图都一次性 decode 进内存。同样的,你也不能在 Agent 里把所有历史一股脑塞进 messages。

先立一个概念:上下文窗口不是"可以用的",是"你要精打细算才能用的"

qwen-plus(32k 上下文)为例,一个合理的预算表大概是这样:

部分预算说明
System Prompt≈ 200 tokens角色设定,稳定,基本不变
摘要(可选)≤ 500 tokens压缩过的长期记忆
最近 N 轮历史≈ 2000~6000 tokens滑窗,动态
RAG 检索资料≈ 1000~3000 tokenstop_k * chunk_size
当前 user 输入≈ 200~500 tokens正常提问
模型输出预留≥ 2000 tokens留给 assistant 写答案
安全冗余≥ 1000 tokens别卡着 32k 上限用

超预算了怎么办?优先砍历史,别砍 RAG。 因为:

  • 历史丢了,可以靠摘要兜底;

  • RAG 资料丢了,这一轮的事实性准确度就直接崩。

怎么数 token?

两种口径:

  1. 精确计数:用 tiktoken(OpenAI 家族)或 dashscope 的 tokenizer。面试时这叫"知道有专用 tokenizer",是加分项。
  2. 工程估算:中文 1 字 ≈ 1.5 token,英文 4 字符 ≈ 1 token;取 len(text) * 0.6 做粗估,误差 ±20%,用来做"超不超预算"的判断完全够。

main_memory.py 里用的是第 2 种:

def estimate_tokens(text: str) -> int:
    # 经验系数 0.6,够做"超预算没"的判断;要精确就换 tiktoken
    return int(len(text) * 0.6)

别小看这个函数。 "会算 token"是中级 Agent 工程师和初级最明显的分水岭之一

七、代码落地:main_memory.py 怎么读

文件完整代码在项目根目录的 main_memory.py。这里只讲几个关键点。

1. 三段式数据结构

class ConversationMemory:
    def __init__(self, max_rounds=6, token_budget=6000, summary_trigger=4000):
        self.history: List[Dict[str, str]] = []   # 只存 user/assistant
        self.summary: Optional[str] = None         # 更早历史的摘要

注意:

  • history不存 system 消息——system 是外面给的、相对静态的;

  • history不存 RAG 检索到的参考资料——资料只贴在"当前这一轮"的 user 内容里;

  • summary 是 LLM 自己生成的、可以不断被刷新。

2. 组装 messages 的主函数

def build_messages(self, current_user_message):
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    if self.summary:
        messages.append({"role": "system",
                         "content": SUMMARY_PREFIX + self.summary})
    messages.extend(self._recent_window())    # 最近 N 轮
    messages.append({"role": "user", "content": current_user_message})
    return messages

这 5 行就是前面"三段式"的全部实现。其他都是围绕它的保护和优化。

3. RAG 资料绝对不写入 history

主循环里:

retrieved = rag.retrieve(user_input, top_k=3)
user_msg_with_context = build_user_message(user_input, retrieved)

messages = memory.build_messages(user_msg_with_context)
response = client.chat.completions.create(model=model, messages=messages, ...)

# ★ 注意:history 只存"用户原话",不存检索资料
memory.add_user(user_input)
memory.add_assistant(answer)

为什么这么做?

  • 资料是本轮才相关的,下一轮问"他的上级是谁",那份资料可能已经无关了;
  • 资料体积巨大,写进 history 会迅速撑爆预算;
  • 历史应当保留的是"用户表达的意图"和"助手给出的结论",不是中间过程。

这个细节是面试里常踩的雷,很多人会把整条 user_msg_with_context 回写进历史,然后下一轮检索出错、token 爆炸,一起翻车。

4. 摘要压缩:在"本轮组装前"触发

if memory.maybe_summarize(client, model=model):
    print("🗜  已对更早的历史做摘要压缩。")

maybe_summarize 做三件事:

  1. 探测:如果 摘要 + 全部历史 的预估 token 没超阈值,直接返回 False;
  2. 压缩:把"超出最近 N 轮"的那部分丢给 LLM,让它按固定模板压成要点;
  3. 刷新:用新摘要替换旧摘要,并把老历史从 self.history 里删掉。

压缩 Prompt 的设计也有讲究:

summarize_prompt = (
    "请把下面的对话压缩成 5-10 条中文要点,覆盖:\n"
    "1) 用户在问/做什么任务;\n"
    "2) 关键结论、已确认事实、偏好;\n"
    "3) 仍未解决的问题或待办。\n"
    "务必简洁,不要引用完整原文,不要编造新事实。\n"
    ...
)

三条约束解决三个常见翻车:

  • "结构化覆盖"防止摘要漏掉关键事实;

  • "不要引用完整原文"防止摘要退化成拼接;

  • "不要编造新事实"防止 LLM 在摘要环节就开始幻觉。

5. 交互指令:Demo 该有的"调试把手"

初级 Demo 只有一个 quit,中级 Demo 至少要有:

指令作用
/reset清空会话历史与摘要,重新开始
/history打印当前维护的 messages
/tokens打印当前上下文预估 token
/summary打印当前摘要内容
/help列出所有指令

这不是花活,是中级工程师的肌肉记忆:任何一个能让你在生产里排错的开关,都应该先在 Demo 里长出来

八、运行起来

# 依赖沿用上一章,不新增
pip install openai python-dotenv qdrant-client sentence-transformers pypdf

python main_memory.py

启动后你会看到:

📚 Qdrant RAG 索引已就绪,chunk 数量: N
🤖 中级 RAG + 多轮 Agent 已启动!输入 /help 查看指令,quit 退出

👉 你: 张三是哪个部门的?
🤖 Agent: 张三是市场部的。
📎 检索来源: demo.md
ℹ️  历史轮数≈1,摘要 无,本次发送≈312 tokens

👉 你: 他的上级是谁?
🤖 Agent: 张三的上级是李四(市场部总监)。
📎 检索来源: demo.md
ℹ️  历史轮数≈2,摘要 无,本次发送≈468 tokens

现在它记得住"他"指的是谁了。

继续聊下去,当 token 估算超过 4000 时,你会看到:

🗜  已对更早的历史做摘要压缩。

这就是策略 B + C 在工作。

九、这个版本能做什么、不能做什么

能做

  • 多轮对话,支持指代消解

  • 滑动窗口控制短期记忆

  • 超预算时自动摘要压缩

  • /reset /history /tokens /summary 可调试

  • 依然保留 RAG 检索与来源追溯

还不能做(对应后续章节)

缺失能力表现对应章节
流式输出必须等整段生成完才看到结果,长回答体验差中级(二)SSE/Streaming
工具调用只会说话,不会查天气、发邮件、写数据库中级(三)Function Calling
检索优化长对话里检索还是按原始 query 走,召回不准中级(四)Rerank / 混合检索 / Query 改写
长期记忆持久化一 quit 历史就没了高级:长期记忆 + 用户画像
服务化不能给 App / 前端调用中级(五)服务化封装
稳定性治理摘要调用失败会怎样?超时呢?重试呢?中级(六)工程化治理

其中"长对话里检索该用什么 query"是下下章的重头戏,我在这里留个伏笔:当用户说"那他呢?"时,你拿这个字符串去做向量检索,召回只会是一堆垃圾。真正该做的是"先用历史做 Query 改写,再去检索"。中级(四)会讲。

十、本章小作业(建议做完再进中级二)

过关标准依然不是"跑通",是"讲得清楚"。

  1. 指代消解实验:连续 3 轮以"他 / 它 / 那个"引用前文,观察 Agent 是否稳定理解;把 max_rounds 改成 1,再试一次,看它什么时候"失忆"。
  2. 预算临界实验:把 summary_trigger 从 4000 降到 500,手动多问几轮,观察摘要触发时机和质量。
  3. 摘要 Prompt 对比:把 maybe_summarize 里的摘要 Prompt 从"三条结构化约束"改成一句"帮我总结一下上面的对话",对比摘要质量。
  4. RAG 资料回写实验(反面教材):故意把 user_msg_with_context 写进 memory.history,看几轮之后 token 曲线如何爆炸、检索如何错位——踩一次你就永远不会再踩
  5. token 估算替换pip install tiktoken,把 estimate_tokens 换成真实计数,对比两种口径的差距。
  6. 讲解挑战:用自己的话画一张"main_memory.py 的上下文三段式结构图",标清楚每一段在代码里是哪个变量;并说明为什么 RAG 资料不进 history

能讲明白第 6 题,你就过了中级(一)这一关。

十一、下一篇预告

下一篇进入中级(二):

《中级(二):流式响应——从"等模型"到"追模型"》

我们会把 main_memory.py 升级成流式版本:

  • 为什么长回答必须做流式(首 token 延迟 vs 总延迟)
  • OpenAI 协议里的 stream=True 到底返回了什么
  • SSE / WebSocket / 轮询三种选择的取舍
  • 流式下的中断、取消、错误恢复
  • 前后端协作时的协议设计(这就是中级(五)的伏笔)

流式是把 Agent 从"能用"变成"好用"的关键一步,也是所有用户可感知的体验改进里性价比最高的一项。

十二、写在最后

回到开篇的三句话:

  1. AI Agent 不是玄学,它就是一门新工程。原生开发者迁移过来不难,难的是没人带。
  2. 真正的门槛不是模型,是工程化落地能力。这恰好是你最擅长的那部分。
  3. 这个系列从第一篇到最后一篇都免费,你要做的只有一件事——跟着练。

这一章结束,你应该已经:

  • 手上有一个能多轮对话、会控制上下文、会摘要压缩的 main_memory.py
  • 心里有一张"system + 摘要 + 最近 N 轮"的三段式结构图;
  • 口里能讲清楚"LLM 的多轮是客户端拼出来的"、"历史和 RAG 为什么要分开"。

这就是中级(一)过关。

下一篇见。