一、从初级到中级,卡在哪一步
上一章我们交付了 main_rag.py:一个能基于私有知识库回答问题、会引用来源、会幂等更新索引的最小 RAG Agent。
跑起来之后你大概率会发现一个很尴尬的现象:
你:张三是哪个部门的?
Agent:张三是市场部的(基于 xxx.md)。
你:他的上级是谁?
Agent:请问"他"是谁?
它每一句都对,但它不记得上一句。
这就是从初级走向中级的第一道分水岭。
我在开篇里画过那张分层表:
| 档位 | 对应 Android | 代表能力 |
|---|---|---|
| 初级 | 能写页面、会调 SDK、懂四大组件 | 会接 LLM、会写 Prompt、能做基础 RAG、能搭 Demo 级 Agent |
| 中级 | 会架构、性能优化、组件化 | 多轮上下文、流式、Tool Use、服务化、检索优化 |
| 高级 | Framework、SDK、跨端架构、主导项目 | Agent 架构、工作流编排、评测体系、成本治理、AI Native 产品落地 |
注意中级第一项:多 轮 上下文。这就是本篇要讲的事。
本章结束时你应该做到三件事:
- 能讲清楚"LLM 的多轮到底是怎么实现的";
- 能把
main_rag.py升级成会记事的main_memory.py; - 能用三种策略(全量 / 滑窗 / 摘要)控制上下文,并讲出它们各自的代价。
配套 Demo:main_memory.py。
二、中级 AI Agent 工程师在真实公司里做什么(JD 拆解)
先把结论放在前面。这一句我不是自己编的,是 JD 原文抄的:
"具备良好的问题拆解能力,能在不确定性较高的 AI 场景中,将业务需求转化为稳定的工程方案。 "
——某消费级智能硬件大厂 · AI 应用开发工程师(智能体)岗位要求
把这句话里的术语拿掉,翻译成大白话:
中级 = 在 AI 这种"东西老变、模型会错、边界不清"的不确定性里,依然能交出稳定工程方案的人。
下面三份 JD,来自 2025 年 Boss 直聘深圳区公开岗位,去除公司名和 HR 信息。看真岗位、看真要求。
1. 中级 JD 画像
| 样本 | 岗位 | 薪资 | 经验 | 方向 |
|---|---|---|---|---|
| JD-Ⓐ | AI 应用开发工程师(智能体)· 某消费级智能硬件大厂 | 20–40K | 3–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 / MCP | ✅ | ✅ | 2 | 1月3日 |
| 流式响应(SSE / streaming) | ✅ | 1 | 0/3 | |
| 多轮对话 / 上下文管理 / Memory | ✅ | ✅ | 2 | 1月3日 |
| Agent 框架(LangChain / LangGraph / AutoGen) | ✅ | 1 | 0/3 | |
| CoT / Few-Shot / Prompt 优化 | ✅ | 1 | 0/3 | |
| RAG + 向量数据库 | ✅ | ✅(认知) | 2 | 3月3日 |
| 业务需求→工程方案(不确定性转化) | ✅ | ✅ | 2 | 2月3日 |
| 对话式 + 非对话式交互设计 | ✅ | 1 | 0/3 | |
| 服务化 / 架构设计 | ✅ | ✅ | 2 | 1月3日 |
*初级命中列复用初级 JD 拆解节的三份样本数据,方便对照。
三条从数据里读出来的结论:
① 中级比初级多出来的 5 个关键词—— Function Calling / Tool Use / MCP、流式响应、多轮对话与 Memory、Agent 框架、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–35K | 26–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 |
| 3 | Tool 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。
工程上的标准做法是:
- 硬窗口:最近 N 轮永远完整保留(对应策略 B);
- 软预算:整个 messages 的 token 数超过阈值时,把"N 轮以外的"折叠成摘要(对应策略 C);
- 系统提示 永远在最前、永远不动。
记住这个三段式:
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 tokens | top_k * chunk_size |
| 当前 user 输入 | ≈ 200~500 tokens | 正常提问 |
| 模型输出预留 | ≥ 2000 tokens | 留给 assistant 写答案 |
| 安全冗余 | ≥ 1000 tokens | 别卡着 32k 上限用 |
超预算了怎么办?优先砍历史,别砍 RAG。 因为:
-
历史丢了,可以靠摘要兜底;
-
RAG 资料丢了,这一轮的事实性准确度就直接崩。
怎么数 token?
两种口径:
- 精确计数:用
tiktoken(OpenAI 家族)或dashscope的 tokenizer。面试时这叫"知道有专用 tokenizer",是加分项。 - 工程估算:中文 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 做三件事:
- 探测:如果
摘要 + 全部历史的预估 token 没超阈值,直接返回 False; - 压缩:把"超出最近 N 轮"的那部分丢给 LLM,让它按固定模板压成要点;
- 刷新:用新摘要替换旧摘要,并把老历史从
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 改写,再去检索"。中级(四)会讲。
十、本章小作业(建议做完再进中级二)
过关标准依然不是"跑通",是"讲得清楚"。
- 指代消解实验:连续 3 轮以"他 / 它 / 那个"引用前文,观察 Agent 是否稳定理解;把
max_rounds改成 1,再试一次,看它什么时候"失忆"。 - 预算临界实验:把
summary_trigger从 4000 降到 500,手动多问几轮,观察摘要触发时机和质量。 - 摘要 Prompt 对比:把
maybe_summarize里的摘要 Prompt 从"三条结构化约束"改成一句"帮我总结一下上面的对话",对比摘要质量。 - RAG 资料回写实验(反面教材):故意把
user_msg_with_context写进memory.history,看几轮之后 token 曲线如何爆炸、检索如何错位——踩一次你就永远不会再踩。 - token 估算替换:
pip install tiktoken,把estimate_tokens换成真实计数,对比两种口径的差距。 - 讲解挑战:用自己的话画一张"
main_memory.py的上下文三段式结构图",标清楚每一段在代码里是哪个变量;并说明为什么 RAG 资料不进history。
能讲明白第 6 题,你就过了中级(一)这一关。
十一、下一篇预告
下一篇进入中级(二):
《中级(二):流式响应——从"等模型"到"追模型"》
我们会把 main_memory.py 升级成流式版本:
- 为什么长回答必须做流式(首 token 延迟 vs 总延迟)
- OpenAI 协议里的
stream=True到底返回了什么 - SSE / WebSocket / 轮询三种选择的取舍
- 流式下的中断、取消、错误恢复
- 前后端协作时的协议设计(这就是中级(五)的伏笔)
流式是把 Agent 从"能用"变成"好用"的关键一步,也是所有用户可感知的体验改进里性价比最高的一项。
十二、写在最后
回到开篇的三句话:
- AI Agent 不是玄学,它就是一门新工程。原生开发者迁移过来不难,难的是没人带。
- 真正的门槛不是模型,是工程化落地能力。这恰好是你最擅长的那部分。
- 这个系列从第一篇到最后一篇都免费,你要做的只有一件事——跟着练。
这一章结束,你应该已经:
- 手上有一个能多轮对话、会控制上下文、会摘要压缩的
main_memory.py; - 心里有一张"system + 摘要 + 最近 N 轮"的三段式结构图;
- 口里能讲清楚"LLM 的多轮是客户端拼出来的"、"历史和 RAG 为什么要分开"。
这就是中级(一)过关。
下一篇见。