第十二周是这门课的正题:Agent。
课程讲了 Agent 的定义演化、四层架构、ReAct 与 OTAC 两种循环、自主决策与任务分解。作业是为 Agent 流程增加多轮对话能力。
做完之后我最大的收获反而不在「记忆」本身,而是发现了一件更基础的事:老师给两个版本写的工具描述不一致,导致其中一版一直选错工具。这篇后半部分讲这个。
一、Agent 的定义为什么一直在变
课程开头先做了一件很诚实的事:承认这个词的定义是流动的。
2022 提示词 Agent 领域模型 + 系统提示词
2023 Q1 记忆 Agent 短期上下文 + 长期向量存储
2023 Q3 工具 Agent Function Call,模型能「做事」
2024 规划 Agent 引入规划层,自主循环执行
2025 通用 Agent 端到端自主、跨应用、Multi-Agent
三个根源:底层能力跃升(每次跃升解锁新形态)、社区定义分裂(研究者/工程师/PM 理解不同)、商业竞争驱动(什么都叫 Agent)。
课程给出的口径很干脆:
Agent = 模型 + 记忆 + 工具 + 规划 + 循环
二、四层架构
用户输入 / 环境信号
↓
感知层 Perception 多源输入 → 组装成 Prompt 上下文
↓
规划层 Planning LLM:理解目标 · 制定计划 · 选工具 · 评估 · 反思
↓
执行层 Execution 调用工具 / API / 代码,回收结果反馈给规划层
↓
外部工具 / API / 数据库 / 浏览器 / 文件系统
记忆层 Memory ← 贯穿全程
规划层是 Agent 的「大脑」——它的质量决定 Agent 的上限。
执行层有个细节我记了下来:dispatch 的核心是 try 执行、except 时把异常信息作为 error 返回,而不是抛出去中断循环。这一条在上周作业里我已经踩过——异常一抛,整个循环就断了,模型连自我纠错的机会都没有。
记忆层分四种:
| 存什么 | |
|---|---|
| 短期 In-Context | 当前对话消息,拼进 Prompt,受 Context Window 限制 |
| 长期 Vector Store | 重要信息向量化存 Chroma / Faiss,每轮检索注入 |
| 情景 Episodic | 具体事件序列(「用户上次说他的项目是 React」) |
| 语义 Semantic | 通用知识和规则(「该用户偏好简洁回答」) |
三、ReAct 与 OTAC
ReAct:交织推理与行动
Thought LLM 的内部推理,分析状态、决定下一步;不触发工具
Action 调用工具,或直接输出 Final Answer
Observation 工具返回的结果,作为新上下文注入,触发下一轮 Thought
三者循环,直到 Final Answer。它解决的是两个极端的问题:「先推理后行动」无法根据中间结果调整,「直接调用工具」没有推理过程也处理不了复杂依赖。
ReAct 的四个局限里,第一条最要命:
无显式验证步骤——行动后没有 Check 环节,工具结果有误时错误会在循环中悄悄累积。
OTAC:加一个 Check
ReAct: Thought → Action → Observation
OTAC: Observe → Think → Act → Check
Check 后有三条路径:
| 触发条件 | 动作 | |
|---|---|---|
| PASS | 结果符合预期 | 写入记忆,进入下一轮 |
| RETRY | 不达预期但方向对 | 调参数或换工具,当前步重试(≤N 次) |
| ROLLBACK | 出错或走进错误路径 | 回退到安全状态,重新 Think |
设计哲学是「先做,再查」——把错误消灭在当步,而不是等任务失败了再回头排查。
四、自主决策的边界
这一节讲的是「自主」不等于「无监督」。
三态决策模型:
- 继续执行 —— 信心充足、信息完整
- 主动停止 —— 不确定性高,向用户提问补齐信息
- 拒绝执行 —— 高风险或越权,明确告知并请求显式授权
原则:默认保守,宁可多问也不要盲目执行高风险操作。
课程还对比了四家企业级 Agent 的做法:
Claude Code 执行前标注风险级别,删文件/写 .env 先展示 diff 等确认
Devin 每个编码操作后截图对比期望状态(与 OTAC 的 Check 吻合)
Manus 沙箱隔离 + 关键节点存快照,用户可回滚
OpenAI Operator 付款、登录、提交表单强制暂停等授权
共性四条:最小权限、可撤销优先(草稿 > 发送,移动 > 删除)、透明行动、人在回路。
五、作业:先分清两个维度
老师的代码是一个 ReAct 范式的 A 股金融分析 Agent,五个工具:rag_search(FAISS,10,353 条年报向量)、company_lookup、financial_indicator、stock_price、calculator。两套实现:手写 Prompt 版和 Function Calling 版。
缺的是多轮对话,两个文件的 run() 一模一样:
def run(question, max_steps=10):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question}, # ← 每次调用都新建
]症状很具体:
问「贵州茅台2023年毛利率是多少」→ 答 91.96%
问「那五粮液呢」 → Agent 不知道「那」指毛利率
问「它的股价怎么样」 → Agent 不知道「它」是谁
容易混的一点
老师的代码已经有循环了(for step in range(1, max_steps+1)),那正是 Week11 作业做的东西。这次要补的是另一个维度:
Week11 循环 = 一个问题内部,Thought→Action→Observation 转几圈 (老师有)
Week12 多轮 = 跨问题的记忆,问完一个下一个还记得 (老师没有)
六、光把历史 append 上去是不够的
rag_search 返回的是 5 段年报原文,实测单条 1,400–2,500 字符。一个问题跑 3 步就 ≈ 4,700 字符,多轮再叠上去就线性膨胀。
所以这次作业必须是两件事一起做:记忆(存历史)+ 上下文管理(截断压缩)。
分层压缩:三层,从松到紧
最近 2 轮 完整保留 ReAct 过程,只把超长 Observation 截到 800 字符
更早的轮次 只留「问题 + 最终答案」,中间工具调用过程全丢
超预算 / 超上限 从最老的轮次开始整轮淘汰
依据是刚发生的事需要细节,久远的事只需要结论:「它的股价呢」要靠上一轮的完整上下文消解指代;三轮前查过年报哪一页,留个结论就够了。
实测:从线性增长变成有界
轮次 不压缩(字符) 压缩后(字符) 占比
1 4,710 2,854 60.6%
3 14,130 5,770 40.8%
8 37,680 6,130 16.3%
13 56,568 6,460 11.4% ← 已淘汰最老的 1 轮
15 56,592 6,478 11.4% ← 已淘汰最老的 3 轮
不压缩线性增长到 56,592 字符,压缩后收敛到 ~6,400 就不再涨了。
三层策略的接管点在曲线上看得很清楚:前 2 轮 60%(只有截断在起作用)→ 第 3 轮掉到 40% 并走平(老轮次压成一问一答,每轮只增约 80 字符)→ 第 13 轮完全持平(开始整轮淘汰)。
七、两套实现的记忆有结构性差异
这一点是动手时才发现的——两版不能用同一套压缩代码。
Function Calling 版:历史是结构化 messages,有配对约束。
带 tool_calls 的 assistant 消息后面必须紧跟 id 对得上的 tool 消息,否则 API 直接报错:
An assistant message with 'tool_calls' must be followed by
tool messages responding to each tool_call_id
所以压缩时只能整轮丢,不能丢单条。(改 tool 消息的 content 长度是安全的,不影响配对。)我为此写了配对校验,把预算压到 3,000 字符(远小于正常值)时仍验证「全部成对」。
手写版:历史是纯文本拼接,没有结构约束。 想截哪截哪,自由度高得多。代价是格式全靠 SYSTEM_PROMPT 约束,而历史文本里塞满了 Action: Observation: 这些字样,历史越长模型越容易被带偏格式。
八、2×2 对比
3 段对话共 8 轮,第一轮是完整问题(warmup 验证对比没跑偏),后 5 轮全部带指代(「那」「它」「这个数」「换成」)。
无记忆(老师原行为) 有记忆(本次改造)
fc 0/5 5/5
manual 0/5 3/5
组合 平均步数 工具调用 耗时(s)
fc+无记忆 1.2 0.2 2.3
fc+有记忆 2.4 1.4 5.9
★ 「无记忆更快」是假象:无记忆版大多数轮直接反问用户就结束了,工具调用平均只有 0.2 次——它根本没干活。快是因为什么都没做。
无记忆的两种失败形态
形态 A:反问用户(3/5)
问:把这个数除以10000是多少?
答:请提供具体的数字,我才能帮您除以10000。
形态 B:撒网式回答(2/5)——这种更隐蔽:
问:那五粮液呢?(上一轮问的是毛利率)
答:五粮液最新财务数据显示:2024年归母净利润318.5亿元…营业总收入891.8亿元…
净资产收益率23.35%…毛利率…
它不知道问的是哪个指标,就把所有指标全报出来,总有一个命中。看起来信息量很大,实际上既没针对问题,年份还报成了 2024(问的是 2023)。
有记忆之后
FC 版 5 轮全对,最能说明问题的是这一条:
问:两家差多少个百分点? → 2 步,只调了一次 calculator
答:贵州茅台2023年毛利率(91.96%)比五粮液(75.79%)高16.17个百分点。
没有重查任何数据——它直接从历史里取出了两个数字。记忆不只是能答出来,还省掉了重复的工具调用。
九、手写版 3/5 的真实原因:不是记忆,是工具描述
这是我这周最意外的发现。
翻明细发现手写版有记忆的两次失败,都是 rag_search 连调 3~4 次找不到数据——它压根没去调 financial_indicator,而那个工具里明明有毛利率。
根因是老师两版对同一个函数的描述不一致:
手写版: financial_indicator(symbol) - 获取实时财务指标(PE/PB/ROE等)
FC 版: 获取A股近3年关键财务指标(营收/净利润/毛利率/ROE/资产负债率等)
而这个工具的实际返回里有:毛利率: 2023年: 91.96。
手写版的描述没写毛利率、没写净利润、没写营收,反倒写了 PE/PB——而这个工具根本不返回 PE/PB。
于是手写版的模型问「毛利率」时不认为这工具能给,转去 rag_search 翻年报原文,而年报 chunk 里没有现成的毛利率数字。
验证:只替换描述那一行
老师原描述 修正描述后
「换成海康威视」 rag_search×5 company_lookup → financial_indicator
「那五粮液呢?」 rag_search×3 81.92%(年报第14页)
只改一行描述,从 5 次徒劳的检索变成 2 步直接命中。
这正好印证笔记里的实战经验:「工具描述要精准,模糊描述是失败主因之一」。
所以 2×2 表里手写版那一行的差距,不能读成「文本记忆不如结构化记忆」。记忆本身是生效的(0/5 → 3/5,失败的两轮都能看到它正确理解了指代,只是选错了工具)。
十、顺带撞到的两个工具缺陷
① 失败会沿着记忆传播
stock_price 依赖的第三方接口会限流。实测一段以它开头的对话:前两轮因为接口挂了没拿到数,第三轮「这两家哪个表现更好」时模型开始病急乱投医,连调 5 次 rag_search 找别的数据来凑,跑满 10 步也没给出答案。
这正是课上说的「幻觉传播:错误的 Observation 被写进上下文,后续所有 Thought 都基于错误上下文推理」。
加了记忆之后,单轮的失败会变成整段对话的失败——这是记忆带来的新风险。
② 大额金额的 10 倍换算坑
工具返回(正确) 营业总收入: 2023年: 4.009e+11 → 4009 亿元
模型的回答(错了)「营业总收入为 400.9亿元(4.009×10¹¹元)」
科学计数法是对的,但换算成「亿」时掉了一位——模型把英文 billion 当成了中文「亿」。
最有意思的是:接下来问「宁德时代是五粮液的几倍」,答案 4.81 倍反而是对的——因为两个数错了同样的倍数,比值把误差抵消了。
单看比值发现不了这个错。
所以选演示题时我避开了大额金额,用百分比类指标(毛利率 / ROE)最稳——百分比直接读取,不需要换算。
这周的收获
1. 记忆的代价是错误会传播。
无记忆时,一轮失败就是一轮失败;有记忆时,一轮的错误 Observation 会污染后面所有推理。这让我理解了为什么 OTAC 要专门加一个 Check——循环 + 记忆 = 错误放大器,必须在每一步拦住它。
2. 工具描述是代码的一部分,而且是最容易被忽略的那部分。
改一行 description,模型的行为从「5 次徒劳检索」变成「2 步命中」。Prompt 里的自然语言就是接口契约,写得含糊等于接口写错。
3. 归因要做到底。
如果我只看 2×2 那张表,会写出「结构化记忆优于文本记忆」这个漂亮但错误的结论。多翻一层明细,才发现问题根本不在记忆上。 这次是靠一次随手复核发现的。
下周讲 Skills 与 Harness——Agent 的能力怎么组织和加载。