Files
python-agent/docs/02-阶段二-AI-Agent核心理论与基础范式/05-阶段二综合实战-手写完整Agent.md
T
2026-09-22 15:42:25 +08:00

227 lines
16 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段二 · 综合实战:手写一个完整可运行的 Agent
> 所属:阶段二 AI Agent 核心理论与基础范式(动手收尾)
> 定位:前面 4 讲是"零件 + 图纸",这一讲把它们**焊成一个能跑的真家伙**。不依赖任何框架、也不需要 API Key,复制到本地 `python3 react_agent.py` 就能看到 Agent 循环完整跑起来。先跑通,再读懂,最后换上真模型。
## 配套文件
- 可运行代码:[`/workspace/code/阶段二/react_agent.py`](../../code/阶段二/react_agent.py)(复制到本地直接跑)
## 一次运行能学到什么
```bash
python3 react_agent.py
```
你会看到 4 个 Demo,分别演示阶段二的核心:
| Demo | 演示点 | 对应概念 |
|-|-|-|
| 1 查天气 | ReAct 主循环:思考→行动→观察→回答 | 经典范式 + 主循环 |
| 2 计算 10/0 | 工具报错被**封装成消息回传**而非崩溃 | 工具结果处理 |
| 3 多意图任务 | 先规划步骤清单再逐条执行 | Plan-and-Execute |
| 4 Reflexion | 失败后反思、带教训重试 | Reflexion 变体 |
## 代码结构(Part 1 ~ 7)
### Part 1:工具注册表(四大组件之「工具调用」)
```python
@dataclass
class Tool:
name: str # 工具名:模型靠它识别
description: str # 描述:决定模型何时触发
parameters_schema: dict # 参数 Schema:喂给模型的 JSON Schema
func: Callable # 真实函数:真正干活的代码
```
- 三个工具:`get_weather`(查天气)、`calculator`(四则运算)、`search_knowledge`(知识库)。
- 注意 `calculator` 的两个细节,都是阶段二强调的工程习惯:
- **白名单校验**:`re.fullmatch` 只允许数字和四则运算符号,防注入。
- **错误回传**:除零 / 非法表达式都 `return "错误: ..."` 而不是 `raise`——这样模型能看到错误并换方案,而不是整个循环崩掉。
> 🔬 **深度理解 · 工具(Tool)与工具抽象:为什么注册的是"描述和 Schema"而非直接传函数**:
> 1. **本质**:工具抽象是一种"把外部能力翻译成模型能理解和调用的语言"的桥——模型并不执行代码,它只"决定要不要用、用什么参数",所以注册的一定是**名字 + 描述 + 参数 Schema + 真实函数**这四元组,而非裸函数。
> 2. **机制**:`Tool(name, description, parameters_schema, func)` 构成注册表;`_tool_schema()` 把注册表转成 OpenAI 的 `tools`(JSON Schema)喂给模型;模型凭 `description` 判断何时触发、按 `parameters_schema` 生成参数;执行时 `_execute` 校验参数、绑定 `func`,再把返回结果统一封装成消息回传。
> 3. **为什么重要**:整个"模型-工具"协作的正确性建立在两件事上——描述是否足以让模型在正确时机选用、Schema 是否足以让模型填对参数;同时白名单与错误回传决定了单次失控的上限,属于安全边界的一部分。
> 4. **易错点/常见误解**:新手以为"描述越简越好",其实工具是否被触发完全取决于描述与 Schema 是否准确;也常把"工具定义"和"工具执行"混为一谈——定义是给模型看的说明书,执行才是真正干活的代码,二者解耦才便于安全插拔。
### Part 2:模拟 LLM(关键设计)
```python
class MockLLM:
def chat(self, messages, tools=None) -> LLMResponse: ...
```
- 用"规则"模仿大模型的行为:历史里已有工具结果 → 给最终答案;否则按用户提问关键词决定调用哪个工具。
- **为什么这么做**:让初学者**不花一分钱、不配 API Key** 也能看到 Agent 循环真实运转。真实模型和 MockLLM 的 `chat()` 接口完全一致,换模型只改一行(见 Part 3)。
> ⏳ **短期可不深究**:MockLLM 里"按关键词判断该调哪个工具、历史里有工具结果就给答案"这类 if-else 逻辑只是为了离线演示,不是真实模型的行为机制。第一遍看懂它的**接口 `chat(messages, tools)` 和数据流**即可,别被规则逻辑误导,以为真模型也是这么"判"的。
```python
@dataclass
class LLMResponse:
content: Optional[str] = None # 模型直接给答案
tool_calls: Optional[list] = None # 模型决定调用工具
```
> 这两个字段就是 OpenAI `chat.completions` 返回里最重要的两部分。看懂 `LLMResponse`,就看懂了所有框架的"模型层抽象"。
### Part 3:OpenAI 适配器(换一行接真模型)
```python
class OpenAILLM:
def __init__(self, api_key, model="gpt-4o", base_url=None): ...
def chat(self, messages, tools=None) -> LLMResponse: ...
```
- 把官方 SDK 的返回翻译成 `LLMResponse`,接口与 `MockLLM` 完全一致。
- 所以从 Mock 切到真实模型只需:
```python
from openai import OpenAI
llm = OpenAILLM(api_key="sk-xxx", model="gpt-4o") # 代替 MockLLM()
agent = Agent(llm=llm, max_turns=5)
agent.run("北京今天天气怎么样?")
```
### Part 4:ReAct 主循环(Agent 基类)
这是整个文件的灵魂,逐段拆解:
```python
def run(self, user_input: str) -> str:
messages = [
{"role": "system", "content": self.system_prompt}, # 立规矩,始终保留
{"role": "user", "content": user_input}, # 用户问题
]
turn = 0
while turn < self.max_turns: # 熔断:最多 N 轮,防死循环
messages = self._trim(messages) # 记忆修剪:超长丢最旧的
resp = self.llm.chat(messages, self._tool_schema())
if resp.tool_calls:
messages.append({...assistant tool_calls...}) # ① 追加"工具调用请求"
for call in resp.tool_calls:
result = self._execute(call) # 执行工具
messages.append({...role:tool, tool_call_id: call.id...}) # ② 回传结果
continue # 回到循环,继续"思考"
return resp.content # 模型给了最终答案 → 结束
return "【熔断】已达最大轮次"
```
配套的四个"工程习惯"方法:
| 方法 | 作用 | 对应概念 |
|-|-|-|
| `_tool_schema()` | 把注册表转成模型的 JSON Schema | 工具定义 |
| `_trim()` | 消息超长丢最旧、永远保留 system | 短期记忆修剪 |
| `_execute()` | 未知工具/参数错/执行错 → 封装成消息回传 | 工具结果处理 + 白名单 |
| `log()` | 每个回合/行动/观察都打日志 | 全链路追踪 |
> 运行 Demo 1 时观察 `[回合] → [行动] → [观察] → [回合] → [完成]` 的顺序,并看"模型调用 2 次"这个数字——这就是循环里每次 `chat()` 都在花 token 的直观证据。
> 🔬 **深度理解 · Agent 主循环的实现本质(run() 里发生了什么)**:
> 1. **本质**:`run()` 不是一次代码运行,而是一个"把每一轮对话追加进 messages、交给 LLM 判断、再根据判断执行工具并回传结果"的 while 循环;所谓"循环实操"就是这个 loop 加上一套消息协议。
> 2. **机制**:循环里的关键是一对成对的协议——先追加模型产出的 assistant 消息(含 `tool_calls`,每个带 `tool_call_id` 与动作),再用**同一个** `tool_call_id` 追加 `role: tool` 的对应结果。这样模型才能把"哪个动作的哪个结果"挂起来,继续下一轮推理,直到它返回不含工具调用的最终答案。
> 3. **为什么重要**:它把阶段二第 3 讲抽象的 ReAct 落成可执行代码;看懂这几十行,就等于看懂了任何框架"抽壳后的 Agent 内核"。循环里的 `_trim`、`_execute`、`log` 恰对应记忆修剪、工具容错、链路追踪。
> 4. **易错点/常见误解**:新手常把"一次 while 迭代"当成"一次 LLM 调用",其实一轮里可能既有工具请求又有多个工具结果;也常漏掉 `role: tool` 必须带 `tool_call_id` 且与请求一一对应的约束——漏了,模型就无法正确把结果关联到动作。
### Part 5:Plan-and-Execute 变体
```python
class PlanAndExecuteAgent(Agent):
def _plan(self, task): ... # 拆步骤:按意图数生成计划
def _execute_step(self, step): ... # 每步路由到对应工具
def run(self, task):
plan = self._plan(task) # ① 先规划
for step in plan: # ② 再逐条执行
results.append(self._execute_step(step))
return 汇总所有子结果
```
- 对比 ReAct:这里**先规划再执行**,一次性拆好步骤,不必每步都问模型"下一步做什么",所以 token 更省。
- 真实场景由模型生成计划;这里用关键词规则拆解,是为了离线可跑。
### Part 6:Reflexion 变体
```python
class ReflexionAgent(Agent):
def run(self, task):
reflection = ""
for attempt in range(self.max_attempts):
result = super().run(task) # 跑一轮普通 Agent
if 成功: return result
reflection = self._reflect(...) # 失败 → 让反思器复盘
# 把教训拼进 system_prompt 再重试
```
- 关键机制:失败后不是盲目重试,而是把"教训"拼进系统提示,让模型**带着上次的错误认知**重新开始。
- 代价:多跑几轮、token 更高;收益:成功率明显提升。
### Part 7:运行入口
```python
if __name__ == "__main__":
main()
```
`main()` 里跑了 4 个 Demo,方便你一次看到所有范式的行为差异。
## 进阶练习(把 Demo 改造成你的)
1. **换真模型**:把 `MockLLM()` 换成 `OpenAILLM(api_key=..., base_url=...)`,试试国内 DeepSeek / 本地 Ollama(只需改 base_url + model)。
2. **加新工具**:照着 `Tool(...)` 的格式往 `TOOL_REGISTRY` 里加一个"查汇率"或"查时间"工具,看 Agent 是否会自动调用它(记得在 `MockLLM.chat` 里加对应规则)。
3. **复现三种失败模式**(阶段二第 3 讲的动手任务):
- 让工具返回 `{"error": ...}` → 观察模型自救。
- 把 `max_turns` 调到 1 且问题需要 2 步 → 观察熔断。
- 让 `MockLLM` 故意返回错误 JSON → 观察解析兜底。
4. **数 token**:`llm.calls` 就是模型调用次数,把它乘以每次消息长度,就能估算一次任务的 token 成本——理解为什么"每轮都调模型"很贵。
> ⏳ **短期可不深究**:"数 token / 按调用次数估算成本"是进阶的省钱优化题,第一遍不必追求精确估量——当前你只需记住"每轮都调模型很贵"这个结论,等真正按 token 计费、需要成本精算的需求出现时再回来做。
## 本节自检
- [ ] 能不看提示,画出 `Agent.run()` 主循环的 `while` 逻辑(含 tool_calls 分支)
- [ ] 能说出 `_execute` 为什么把错误 return 成消息而不是 raise
- [ ] 已跑通 `react_agent.py` 的 4 个 Demo,并至少改造成:换真模型 或 加一个新工具
- [ ] 能说清 ReAct / Plan-and-Execute / Reflexion 在代码上的核心差异
## 本节配套思考题(快速入门的检验)
1. `Agent.run()` 里如果去掉 `_trim()`,跑一个特别长的多轮任务会发生什么?结合"上下文窗口"回答。
2. `_execute()` 对"未知工具"返回错误消息,这背后是"白名单"思想。如果换成"黑名单"(只禁高危),风险在哪?
3. Plan-and-Execute 用 `_plan` 一次性拆步骤,省在哪、险在哪?什么情况下它反而比 ReAct 更差?
4. Reflexion 把教训拼进 `system_prompt`,和直接拼进对话历史有什么区别?哪种更不容易"串味"到新任务?
5. 如果把 `max_turns` 设为 1,Demo 1 会发生什么?跑一下验证你的判断。
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:为什么要把 LLM 封装成 `LLMResponse` 与 MockLLM/OpenAILLM 两层?"换一行接真模型"背后靠什么做到?
- **面试官想考察**:是否理解"接口抽象"这一软件工程手段在 Agent 里的必要性,以及是否真的懂"依赖注入"而非只是看过这个 Demo。
- **专业作答(含深度)**:
1. **切的原因**:Agent 主循环只关心"给消息和工具、拿到'要不要调工具'的决策",并不关心底层是 Mock 还是真实 OpenAI;把差异收敛到统一 `chat(messages, tools) -> LLMResponse` 接口上,主循环与厂商 SDK 彻底解耦。
2. **`LLMResponse` 是抽象契约**:它只保留 `content`(最终答案)和 `tool_calls`(模型决定调哪些工具)两个字段——这正是 OpenAI `chat.completions` 返回里 Agent 层真正在乎的两部分,其余响应元数据被隔离在适配器内。
3. **机制**:`OpenAILLM.chat()` 把官方 SDK 的返回翻译成 `LLMResponse`,因此 `Agent(llm=...)` 通过注入不同对象即可切换实现,伪代码"换一行"实指"注入对象换一个",主循环零改动。
4. **价值**:便于测试(Mock 注入跑通流程)、便于切换/多模型(DeepSeek、Ollama 只需换 base_url+model)、便于下沉到框架时对齐"模型层抽象"。
- **加分亮点 / 深度追问**:补"这是策略模式 / 适配器模式的落地,也让 Agent 逻辑与模型厂商解耦";追问:MockLLM 会不会误导对真实模型的理解?答:会,它只是替身,判据只在"接口一致、数据流一致",真正理解了协议才是目的。
### 面试题 2:为什么 `role: tool` 必须用同一个 `tool_call_id` 回传?漏掉或乱配会发生什么?
- **面试官想考察**:是否真懂"工具调用的成对协议",这是手写 Agent(也是如实用官方 SDK)时最容易被坑的地方之一。
- **专业作答(含深度)**:
1. **协议要求**:当模型返回带 `tool_calls` 的 assistant 消息时,聊天协议要求对每个 `tool_call_id` 必须追加一条 `role: tool` 的结果消息,且 id 与请求一一对应。
2. **原因**:模型需要把"每个动作"与其"真实结果"精确配对,才能基于正确反馈继续推理;id 就是关联二者并维持有序性的键。
3. **漏掉/乱配的后果**:要么校验报错(缺结果),要么结果挂到错误动作上,导致模型基于错乱信息推理出幻觉或错误结论;在支持并行多工具调用时尤其致命。
4. **与本 Demo 的关系**:主循环里"先追加 assistant 的 tool_calls,再 for 循环逐一追加 tool 结果"这两步就是该协议的完整落地,也是最该手写吃透的一段。
- **加分亮点 / 深度追问**:可补"多工具并行调用时,每个 id 仍要各自成对,顺序可乱、配对不能错";追问:收到 `role: tool` 但模型并不再返回 tool_calls 会出现什么?答:说明 Agent 判定任务完成、给出最终答案,循环正常终止。
### 面试题 3:ReAct / Plan-and-Execute / Reflexion 在这份代码里各自的核心差异在哪?为什么说它们是同一内核的不同变体?
- **面试官想考察**:能否从"类继承 + 策略"角度讲清三种范式的代码差异,而非停留在概念复述。
- **专业作答(含深度)**:
1. **共用内核**:三者都复用 `Agent` 基类的 `_tool_schema / _execute / _trim / log` 等"循环基础设施",差异只在于"如何决策下一步"这一步的策略。
2. **ReAct**:基类本身——每轮都让 LLM 判断"下一步调哪个工具",`while` 内一问一执行,出现 `tool_calls` 就继续、否则返回最终答案。
3. **Plan-and-Execute**:在基类外套一层"先 `_plan` 一次性拆步骤、再逐条 `_execute_step`"的变体,把"每步征询模型"变成"执行定好的清单",这就是 token 更省的代码出处。
4. **Reflexion**:在 `run()` 外层用 `for attempt` 循环,失败后让反思器生成 `reflection` 并把它拼进 system_prompt 再重跑——把"教训注入"体现在 `super().run()` 的输入构造上。
- **加分亮点 / 深度追问**:可补"三者都缺不了熔断与错误回传,AutoGPT 的教训正是少了这些护栏";追问:ReAct 基类同时被三者复用,会不会让变体出现耦合?答:会,所以规范化时要把"决策策略"再抽象一层(如执行器/规划器/反思器接口),本 Demo 为入门做了简化。