📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 76:Agent 本质 --- 徒手实现 ReAct 循环 🤖
FDE 学习系列教程 · 第三阶段 · 第 12 周 · Day 1 预计时长:3 小时 | 难度:★★★☆☆ | 前置知识:Day 51-75(LLM 调用、结构化输出、Jinja2 模板、RAG 全链路、v0.2 冻结)+ 第二阶段第 6 周(FastAPI、Pydantic v2、httpx) 对标大纲课时:3.3.1 Agent 概念 ------ 大纲原文「从 LLM 到自主决策;感知→规划→行动→观察循环;ReAct 范式|实践:理解 Agent 执行循环」
📌 一句话目标:不碰任何 Agent 框架,用约 150 行 Python 手写一个真正能跑的 ReAct 循环(感知→规划→行动→观察),把模型从"会说"变成"会做"。
🧑🤝🧑 开场:模型会说,但不会做
先看一眼你的系统现在干什么。Day 75 冻结的 v0.2 已经很强了:
┌── 师傅:A3 注塑机冷却水压低怎么办?─────────────────────────┐
│ 系统:检索手册 → 重排 → 组装上下文 → DeepSeek 生成 │
│ 回答:依据 §4.3,低于 0.25MPa 必须停机检查 [1] │
│ 参考来源:[1] manual_a3.clean.md · 4.3 冷却系统维护 │
└───────────────────────────────────────────────────────────┘
✅ 答得准、有出处、有权限控制 ------ Day 73 的 RAGAS 报告可以证明
然后今天早上,车间来了这么一个需求。李班长拿着手机走到你工位旁:
"小师傅,我能不能直接跟系统说一句话:
『A3 今天早上报了超温,帮我看看手册怎么说,顺便把没关的工单都列出来,
如果超过两条就照着上次那样再建一条跟进单。』
一个问题要查三样东西、还要自己决定要不要建单。
你们现在这个......能做到吗?"
你心里很清楚:做不到。 v0.2 的设计是"一问一答"------输入一句话,输出一段话,中间是一次召回 + 一次生成。它没有任何"我自己去查一下"的能力,更没有"查完再决定下一步"的能力。
这四条缺口的正式名字叫 Agent(智能体)。注意这个词的量级:
第二阶段 设备告警工单闭环(FastAPI + MySQL + Docker + 飞书通知)
↓
v0.1(Day60) 工单分类 / 信息提取 / 巡检报告 ------ 模型【读懂一句话】
↓
v0.2(Day75) 知识库问答 + 工单详情页「查手册」联动 ------ 模型【读得懂全部资料】
↓
第 12 周 🆕 运维 Agent v0.3 雏形 ------ 模型【能查、能建单、能自己决定下一步】
↓
Day 85:v0.3(带 trace) → Day 90:v0.4 → Day 100:FDE 智能运维助手 v1.0
今天的任务非常明确:不借助任何框架 ,用最朴素的 while 循环 + 一个 dict 工具表 + 正则解析,把 Agent 跑起来。为什么第一天不许用 LangChain?
因为框架会把所有细节藏起来:用了之后你会觉得"Agent 就是 create_react_agent() 一行",客户再问"它为什么会卡在第 6 轮""能不能限制它别连续建两张单",你就答不上来了------因为你从没见过那个 while 循环长什么样。今天把循环手写一遍,Day 77-79 你才知道 LangChain / LangGraph 在替你做什么、藏了什么、什么时候该绕开它们。
今天五件事:① 搞清楚 Agent 和普通 LLM 调用的差别在哪;② 感知→规划→行动→观察 这四步各自长什么样;③ ReAct 范式(Thought / Action / Observation 三段格式);④ 徒手工具层 + 最小循环,要能真的跑出一个多轮调用的答案;⑤ 三种必踩的坑 + 三道护栏(最大轮次 / 重复检测 / 观察截断)。
💡 今天的关键词是"循环"。 之前 11 周你写的代码全是直线 (输入→处理→输出),从今天开始出现环 。有环的系统才会出现以前没有的故障:死循环、成本失控、 反复重复同一句话。今天写的每一行护栏,都是未来在客户现场救你的那根绳子。
📖 一、Agent 是什么:给模型装上「感知」和「手脚」
1.1 一句话定义
抛开营销话术,工程上的定义就一句:
Agent = LLM + 工具集 + 循环决策
① LLM 负责"想":这一轮该干什么、干完了没、下一步去哪
② 工具集 负责"做":查库、调接口、算数、发通知
③ 循环 负责"转":把工具结果喂回 LLM,让它再想一轮,直到它说"可以回答了"
三个缺一个都不叫 Agent:只有 LLM 是聊天机器人(全是编的);LLM + 工具但无循环 只是一次函数调用(只能查一个地方,不会自己规划多步);LLM + 工具 + 循环才是 Agent(ReAct / AutoGPT / 你今天要写的东西)。
⚠️ 很多人把"函数调用"当成 Agent,这是第 12 周第一个要纠正的认知。 函数调用是"模型说:请替我调 f(x)",然后**人(你的代码)**拿到结果,程序结束。它没有第二次决策。真正的差别在于:谁决定下一步------函数调用里是写死的你,Agent 里是模型。
1.2 感知 → 规划 → 行动 → 观察
这四个词是 Agent 的通用骨架,务必记牢,后面五天每天都在这四个格子里转。
┌──────────────────────────────────────────┐
│ │
▼ │
┌────────────────┐ │
│ ① 感知 Perceive│ 读进来什么问题、现在手上有什么工具、 │
│ │ 之前几轮已经查到过什么(上下文/记忆) │
└───────┬────────┘ │
▼ │
┌────────────────┐ │
│ ② 规划 Reason │ 我现在缺什么信息? │
│ │ 哪个工具能给我这个信息?参数是多少? │
│ │ 够不够回答了?够了就收尾 │
└───────┬────────┘ │
▼ │
┌────────────────┐ │
│ ③ 行动 Act │ 你的代码去真正执行那个工具 │
│ │ (httpx 打接口 / SQL 查库 / Python 算)│
└───────┬────────┘ │
▼ │
┌────────────────┐ │
│ ④ 观察 Observe │ 把工具返回值加工成一段文本 │
│ │ (这不步最关键:格式不对模型就瞎) │
└───────┬────────┘ │
│ │
└───────────────────────────────────────────┘
循环,直到 规划 说"够了"
四个词的工程落点要对应准确,这是面试和评审都会被问的地方:
| 环节 | 谁实现 | 代码里的位置 | 常见错误 |
|---|---|---|---|
| 感知 | 你的循环 | 组装 messages(system + 历史 + 当前问题 + 工具描述) | 忘记把上一轮的观察塞回去 |
| 规划 | LLM | 模型输出的 Thought: / Action: 文本 |
提示词没要求它"先想再做",直接跳到行动 |
| 行动 | 你的代码 | TOOLS[name].run(**args) |
没做参数校验,模型填错一个字段名就 500 |
| 观察 | 你的代码 | 把返回值转字符串并塞进 messages | 直接丢 dict 的 repr、或丢整个 10MB JSON |
1.3 用一句人话讲清「多轮循环」到底在解决什么
李班长那个需求,Agent 的实际执行可能是这样的:
【第 1 轮】
Thought: 用户要三件事。先确认 A3 有没有超温告警记录 → 查未关闭工单
Action : list_tickets 参数 {"device_id": "A3", "status": "open"}
Observe: 共 2 条:WO-0930-01(超温待处理)、WO-1001-03(漏油处理中)
【第 2 轮】
Thought: 已有 2 条未关闭。用户说"超过两条才建跟进单" → 未超过,不用建。
另外用户要"手册怎么说",再查一下 §3.2
Action : search_manual 参数 {"query": "料筒超温处置步骤", "device_id": "A3"}
Observe: 【3.2 料筒超温处置】料筒设定温度 80℃,超过 85℃ 触发高温告警...
【第 3 轮】
Thought: 信息齐了,未超阈值不需要建单,可以给出最终答案。
Final Answer: A3 当前未关闭工单 2 条(...);手册 §3.2 建议...;未创建新单。
注意第 2 轮的 Thought :它改变了原计划(决定不建单)。这个"根据上一轮结果改写后续计划"的能力,就是 Agent 与普通 pipeline 的分水岭。
📌 Agent 的价值不在于能调工具,而在于「循环产生的自适应」。 如果任务的步骤顺序是固定的(先 A 后 B 再 C),你就应该写 Straight-line 代码,不需要 Agent------写死的代码更便宜、更快、更可控。只有当"下一步取决于上一步的结果"时,Agent 才值那个钱。这一条是今天最贵的经验,Day 79 讲图编排时还会回来。
🖥️ 二、徒手实现一个工具层(不用 @tool 装饰器)
Day 78 会用 LangChain 的 @tool 装饰器。今天偏不用------先看看装饰器底下藏着什么。
工具的本质就是四样东西,装在同一个对象里:① name (模型的标识符,英文动词开头,如 get_ticket);② description (给模型看的说明书,写得含糊它就不会用);③ schema (参数结构:字段名、类型、是否必填、值域);④ run() (真正执行的代码)。模型看得见的只有 ①②③,看不见 ④------所以"说明书错,调用必然错"。这是 Day 78 的核心,今天先埋一根线。
2.2 实操步骤 1:准备环境与项目骨架
cd fde-ai
.\.venv\Scripts\Activate.ps1
# Day76 需要的包(沿用 openai SDK 直连 DeepSeek,先不装框架)
pip install openai python-dotenv httpx pydantic
确认三件事:.env 里只有 DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL / DEEPSEEK_MODEL / TICKET_API_BASE 四个环境变量,没有任何写死的密钥 ;.gitignore 必须包含 .env、__pycache__/、chroma_db/;全文可以用 Ctrl+Shift+F 搜 sk-,结果应当是 0 条真实密钥。
2.3 实操步骤 2:工具层的第一版(agent/tools.py)
先让自己的代码能跑起来,不依赖第二阶段服务是否在线 :内置一组示例工单,TICKET_API_BASE 通的时候走真接口,不通就退回本地样本。这个"打不通就降级"的设计,是第二阶段养成的老习惯------客户现场网络说断就断,工具层必须能在缺依赖的情况下跑出结果。
新建 agent/tools.py:
"""agent/tools.py ------ Day76:徒手实现的工具层(不依赖任何 Agent 框架)
一个工具 = name + description + schema + run()
今天先把这四样用最直接的方式写出来,Day78 用 @tool 装饰器包一层就懂了。
"""
from __future__ import annotations
import ast
import json
import operator
import os
from dataclasses import dataclass, field
from typing import Callable
TICKET_API_BASE = os.getenv("TICKET_API_BASE", "http://localhost:8000")
MAX_LIST = int(os.getenv("TOOL_MAX_LIST", "8")) # ⭐ 观察结果长度闸门
# ─────── ① 工具定义容器 ───────
@dataclass
class Tool:
"""一个工具:名字 + 说明书 + 参数 schema + 真正干活的 run"""
name: str
description: str # ⭐ 给模型看的;写不好它就不会用
parameters: dict # JSON Schema(手写,Day78 会自动生成)
run: Callable[..., str]
dangerous: bool = False # ⭐ 写操作标记,Day80 用它做人工审批
required: list[str] = field(default_factory=list)
def brief(self) -> str:
"""渲染成提示词里的一段:模型只看得到这个"""
props = self.parameters.get("properties", {})
rows = [f" - {k}{'*' if k in self.required else ''} ({v.get('type', 'string')}): "
f"{v.get('description', '')}" for k, v in props.items()]
tag = " ⚠️【危险操作,需人工确认】" if self.dangerous else ""
return f"{self.name}{tag}\n 说明:{self.description}\n" + "\n".join(rows)
def call(self, kwargs: dict) -> str:
"""执行 + 统一异常转【错误信息字符串】(⭐ 不许把异常抛到循环外)"""
try:
props = self.parameters.get("properties", {})
missing = [k for k in self.required if kwargs.get(k) in (None, "")]
if missing:
return f"[错误] 缺少必填参数:{missing}。请重试并补全这些字段。"
unknown = set(kwargs) - set(props)
if unknown:
return (f"[错误] 未知参数:{sorted(unknown)}。"
f"该工具只接受:{sorted(props)}")
return str(self.run(**kwargs))
except Exception as e: # noqa: BLE001
# ⭐ 异常翻译成模型看得懂的话,让它自己纠正(6.2 节验证效果)
return (f"[错误] 工具 {self.name} 执行失败:{type(e).__name__}: {e}。"
f"请检查参数后重试,或改用其他工具。")
# ─────── ② 具体工具实现 ───────
SAMPLE_TICKETS = [
{"ticket_id": "WO-20260930-01", "device_id": "A3", "status": "open",
"priority": "high", "title": "料筒温度 91℃,超温告警未消"},
{"ticket_id": "WO-20261001-03", "device_id": "A3", "status": "open",
"priority": "medium", "title": "液压油管接头处渗油"},
{"ticket_id": "WO-20260925-07", "device_id": "A3", "status": "closed",
"priority": "high", "title": "加热圈第 3 段电阻异常,已更换"},
{"ticket_id": "WO-20261002-11", "device_id": "B2", "status": "open",
"priority": "low", "title": "冲压机冷却水压偏低"},
]
MANUAL_SNIPPETS = [
("3.2 料筒超温处置", "A3 注塑机料筒设定温度 80℃,超过 85℃ 触发高温告警。"
"禁止在料筒温度未降至 60℃ 以下时打开防护罩。"),
("4.3 冷却系统维护", "冷却水进水压力正常范围 0.3~0.5MPa,低于 0.25MPa 必须停机检查。"),
("6.1 加热系统", "加热圈单段电阻标准 18±2 欧姆。更换前必须切断主电源并挂牌上锁(LOTO)。"),
("7.4 液压系统", "液压油每 500 小时更换一次。伺服阀阀芯卡滞会导致锁模力下降。"),
]
def _list_tickets(device_id: str | None = None, status: str = "open",
limit: int = 5) -> str:
"""列工单。优先打第二阶段 FastAPI;不通则降级到本地样本。
⭐ 返回值三原则:① 给模型读的紧凑文本而非 list/dict;② 超 MAX_LIST 要截断并说
明理由(模型下一轮会自己收紧条件);③ 数据源降级必须在结果里写清楚。
"""
import httpx
source = "第二阶段工单接口"
try:
r = httpx.get(f"{TICKET_API_BASE}/api/tickets", timeout=3.0,
params={"device_id": device_id, "status": status, "limit": limit})
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}")
rows = r.json()
except Exception: # noqa: BLE001
source = "本地样本"
rows = [t for t in SAMPLE_TICKETS
if (device_id is None or t["device_id"] == device_id)
and (status == "all" or t["status"] == status)][:limit]
if not rows:
return (f"未查询到符合条件的工单(device_id={device_id}, status={status})。"
f"可尝试把 status 改成 all,或去掉 device_id 再查。")
lines = [f"共 {len(rows)} 条工单(来源:{source}):"]
for t in rows[:MAX_LIST]:
lines.append(f" - {t['ticket_id']} | {t['device_id']} | {t.get('status')} "
f"| {t.get('priority')} | {t.get('title')}")
if len(rows) > MAX_LIST:
lines.append(f" ...... 另有 {len(rows) - MAX_LIST} 条被截断,"
f"请缩小 device_id 范围或指定更严格的 status")
if source == "本地样本":
lines.append("⚠️ 工单接口当前不可达,以上为本地样本数据,向用户汇报时必须说明。")
return "\n".join(lines)
def _get_ticket(ticket_id: str) -> str:
"""查单条工单详情"""
import httpx
try:
r = httpx.get(f"{TICKET_API_BASE}/api/tickets/{ticket_id}", timeout=3.0)
if r.status_code == 200:
return json.dumps(r.json(), ensure_ascii=False)
except Exception: # noqa: BLE001
pass
for t in SAMPLE_TICKETS:
if t["ticket_id"] == ticket_id:
return json.dumps(t, ensure_ascii=False)
return f"未找到工单 {ticket_id}。请确认工单号格式,形如 WO-20261001-03。"
def _search_manual(query: str, device_id: str | None = None) -> str:
"""查手册:优先复用 Day71 的知识库;不可用则退回内置片段"""
try:
from kb.retriever import retrieve
hits = retrieve(query, top_k=3, device=device_id)
if hits:
return "\n\n".join(f"[{i+1}] 章节:{h['chapter']}(相似度 {h['similarity']:.2f})\n{h['text']}"
for i, h in enumerate(hits))
return f"手册中未检索到与「{query}」相关的章节。"
except Exception: # noqa: BLE001
pass
kw = set(query) & set("超温冷却加热液压")
hits = [c for c, t in MANUAL_SNIPPETS if set(c) & kw] or [MANUAL_SNIPPETS[0]]
return "\n\n".join(f"[{i+1}] 章节:{c}\n{' '.join(t for ct, t in MANUAL_SNIPPETS if ct == c)}"
for i, c in enumerate(hits))
def _calc(expression: str) -> str:
"""安全算数:只做四则运算,禁止 eval 裸奔"""
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
try:
tree = ast.parse(expression.strip(), mode="eval")
except SyntaxError as e:
return f"[错误] 表达式不合法:{e}"
def _eval(node):
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in OPS:
return OPS[type(node.op)](_eval(node.operand))
raise ValueError(f"不支持的语法节点 {type(node).__name__}")
try:
return f"{expression} = {round(_eval(tree), 4)}"
except ZeroDivisionError:
return "[错误] 除数为零"
except Exception as e: # noqa: BLE001
return f"[错误] 计算失败:{e}。表达式只能包含数字与 + - * / ** ()"
def _create_ticket(device_id: str, title: str, priority: str = "medium") -> str:
"""建工单(危险操作:会真的写库)"""
import httpx
try:
r = httpx.post(f"{TICKET_API_BASE}/api/tickets", timeout=5.0,
json={"device_id": device_id, "title": title,
"priority": priority, "source": "agent"})
if r.status_code in (200, 201):
body = r.json()
return f"✅ 工单已创建:{body.get('ticket_id') or body.get('id') or '(未返回工单号)'}"
return f"[错误] 创建失败,HTTP {r.status_code}:{r.text[:200]}"
except Exception as e: # noqa: BLE001
return (f"⚠️ 工单服务不可用({type(e).__name__}):本次未创建任何工单。"
f"如需人工处理,请记录:设备 {device_id} / 事项「{title}」/ 优先级 {priority}")
# ─────── ③ 注册表:模型能用的全部工具都在这 ───────
TOOLS: dict[str, Tool] = {
"list_tickets": Tool(
name="list_tickets",
description="按设备号和状态查询工单列表。想知道某台设备【有哪些未处理问题】时用这个。"
"status 可选 open(未关闭)/ closed(已关闭)/ all(全部);默认 open。"
"结果最多返回 8 条,超出会被截断,需要精确请用更严格的过滤条件。",
parameters={"type": "object", "properties": {
"device_id": {"type": "string", "description": "设备编号,如 A3、B2;不填表示全部设备"},
"status": {"type": "string", "description": "工单状态:open / closed / all,默认 open"},
"limit": {"type": "integer", "description": "返回条数上限,默认 5,最大 8"}}},
run=_list_tickets, required=[]),
"get_ticket": Tool(
name="get_ticket",
description="查询某一张工单的完整详情。必须已知工单号时使用(形如 WO-20261001-03)。"
"只知道设备号不知道单号时,请先用 list_tickets。",
parameters={"type": "object", "properties": {
"ticket_id": {"type": "string", "description": "工单号,格式 WO-YYYYMMDD-NN"}}},
run=_get_ticket, required=["ticket_id"]),
"search_manual": Tool(
name="search_manual",
description="在设备维护手册中检索处置步骤、参数阈值与安全注意事项。"
"回答『怎么处理』『标准值是多少』『有什么禁忌』这类问题时必用。"
"手册里没有的内容会明确告诉你,此时不要擅自补全,"
"应原样告知用户『手册中未提及』。",
parameters={"type": "object", "properties": {
"query": {"type": "string", "description": "检索问题,用完整疑问句效果更好"},
"device_id": {"type": "string", "description": "限定设备编号,如 A3;不填则全库检索"}}},
run=_search_manual, required=["query"]),
"calc": Tool(
name="calc",
description="计算数学表达式,用于换算停机时长、百分比、成本等。"
"只支持数字与 + - * / ** 和括号,不支持函数调用与变量。",
parameters={"type": "object", "properties": {
"expression": {"type": "string", "description": "如 (91-85)/85*100 或 3.5*220"}}},
run=_calc, required=["expression"]),
"create_ticket": Tool(
name="create_ticket",
description="创建一张新的运维工单。【注意】这是写操作,会真的写进工单系统,"
"只有在用户明确提出『建一张单/立项跟进』时才允许调用。",
parameters={"type": "object", "properties": {
"device_id": {"type": "string", "description": "设备编号,必填,如 A3"},
"title": {"type": "string", "description": "工单标题,一句话描述故障现象,必填"},
"priority": {"type": "string", "description": "优先级:low / medium / high,默认 medium"}}},
run=_create_ticket, required=["device_id", "title"], dangerous=True),
}
def render_tool_catalog() -> str:
"""把工具表渲染成提示词里的『可用工具』章节"""
return "\n\n".join(t.brief() for t in TOOLS.values())
python -c "from agent.tools import render_tool_catalog, TOOLS
print(render_tool_catalog()[:300])
print(TOOLS['list_tickets'].call({'device_id': 'A3', 'status': 'open'}))
print(TOOLS['calc'].call({'expression': '(91-85)/85*100'}))"
list_tickets
说明:按设备号和状态查询工单列表。想知道某台设备【有哪些未处理问题】时用这个...
- device_id (string): 设备编号,如 A3、B2;不填表示全部设备
- status (string): 工单状态:open / closed / all,默认 open
- limit (integer): 返回条数上限,默认 5,最大 8
get_ticket
说明:查询某一张工单的完整详情。必须已知工单号时使用...
- ticket_id* (string): 工单号,格式 WO-YYYYMMDD-NN
...
直接调工具的输出:
共 2 条工单(来源:本地样本):
- WO-20260930-01 | A3 | open | high | 料筒温度 91℃,超温告警未消
- WO-20261001-03 | A3 | open | medium | 液压油管接头处渗油
⚠️ 工单接口当前不可达,以上为本地样本数据,向用户汇报时必须说明。
(91-85)/85*100 = 7.0588
{"ticket_id": "WO-20261001-03", "device_id": "A3", "status": "open", ...}
💡 FDE 的判断标准 :工具的返回值必须是给模型读的一段话 ,不是给程序读的一个 dict。看上面
list_tickets的返回------它写成"- WO-xxx | A3 | high | 故障标题"这样的紧凑文本,而不是 JSON dump。JSON 花括号占了 token 却没增加信息量,长列表里能浪费 40% 的上下文。 这是"观察"这一步最容易被忽略的优化。
📖 三、ReAct 范式:把「想」和「做」写成固定格式
3.1 ReAct 是什么
ReAct 是 2022 年的一篇论文(Reasoning and Acting),第 12 周所有 Agent 的老祖宗。它的想法朴素得不得了:
⭐ 让模型在同一个回答里交替输出【推理】和【动作】:
Thought(想):我缺什么、我打算怎么办
Action(做) :调哪个工具、参数是什么
Observation(看):工具返回了什么 ← 由你的代码填进下一轮上下文
......重复若干轮......
Final Answer(答):给用户的话
为什么这样有效?
① Thought 逼模型【先想清楚再动手】,直接给 Action 的错误率显著更高
② 每一轮的 Thought 是【对下一步的显式规划】,相当于自带 CoT(Day54 学过)
③ Observation 是【外部事实】,能打断模型的幻想 ------ 这正是 Day 61 说的硬伤三的解药
对照 Day 54 你学过的推理引导,ReAct 其实是它的一个特例:
| 范式 | 输出结构 | 有无外部事实 | 典型问题 |
|---|---|---|---|
| CoT(Day 54) | 一串推理 → 答案 | ❌ 全靠内部知识 | 编数字、编流程 |
| Tool Calling(无循环) | 一个 JSON 调用 → 结束 | ✅ 一次 | 只能单步,不能多看几眼 |
| ReAct | 想→做→看 循环 | ✅ 多轮累积 | 可能死循环(今天的重点) |
| Plan-and-Execute(Day 82) | 先出完整计划 → 依次执行 | ✅ | 计划赶不上变化就得重规划 |
3.2 为什么用「文本格式」而不是 JSON?
ReAct 原始实现用的是纯文本(Thought: / Action: / Action Input:),而不是结构化的 {"tool": ..., "args": ...}。原因有两个,都很实在:
① 模型输出 Thought 的过程本身就在"想" ------ 换成 JSON,那一刻的思考就消失了
(你可以事后自己试试:把提示词里"必须输出 Thought"这句删掉,错误率肉眼可见上升)
② 文本格式对【中小型模型】极其友好:DeepSeek、Qwen 在这种格式上更稳,
函数调用 JSON 需要模型专门训练过才会可靠
⚠️ 但纯文本有个硬伤:解析靠正则,模型写歪了就崩。
→ 所以现代做法是用【原生 function calling / tool calling】(Day 78),
ReAct 文本格式在今天的价值是【让你看清结构】。
📌 一句话记住今天的选择 :Day 76 用文本 ReAct 是为了把机制讲明白 ;从 Day 78 起你会换回
llm.bind_tools()的结构化调用。生产一律用结构化,学习一律先手写。 这也是 Day 76 存在的全部理由。
🖥️ 四、动手写循环:150 行跑通一个 Agent
现在把前面四样东西拼起来。这一节是整个第 12 周最核心的代码,请自己敲一遍,不要复制。
4.1 先看循环骨架
messages = [system, user] ← ① 感知:组装初始上下文
steps = []
for i in range(max_steps): ← ② 循环(⭐ 有上界,不许 while True)
reply = llm(messages) ← ③ 规划:模型输出 Thought+Action
if "Final Answer" in reply: return ← 它说够了 → 收尾
name, args = parse(reply) ← 解析工具名与参数
if 重复了上文同一次调用: break ← ④ 护栏一:重复检测
obs = TOOLS[name].call(args) ← ⑤ 行动:真执行(异常转字符串)
obs = truncate(obs) ← ⑥ 观察:截断长结果
messages.append(assistant=reply) ← ⑦ 把"想+做"原样存回上下文
messages.append(user=f"Observation: ...") ← 把"看"塞回去
steps.append(记录 : for trace) ← ⑧ 留痕(Day 85 要接 Langfuse)
return 兜底答案 ← ⑨ 转满圈也没有 Final → 降级而非崩
九个环节里,⑦⑧⑨ 是新手最容易漏的:忘记把 Observation 塞回 messages,模型每轮都看到同样的输入,于是每轮输出同样的 Thought------这就是最常见的"Agent 卡住了"。
4.2 实操步骤 3:统一的 LLM 调用(agent/llm.py)
把 OpenAI 兼容 SDK 封装一下,顺便记 token 和耗时。这个地方全项目只有一份,Day 77 换成 LangChain 时只改这一个文件。
"""agent/llm.py ------ Day76:DeepSeek 统一入口(记 token / 记耗时 / 可重试)
全项目只在这里出现 openai.OpenAI。Day77 换成 LangChain 时,Agent 主循环一行都不用改。
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from typing import Any
import httpx
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
_client: OpenAI | None = None
@dataclass
class Usage:
"""一次调用的消耗:成本与延迟全靠它查"""
prompt_tokens: int = 0
completion_tokens: int = 0
latency_ms: int = 0
@property
def total(self) -> int:
return self.prompt_tokens + self.completion_tokens
def get_client() -> OpenAI:
"""单例客户端。⭐ max_retries=0:SDK 自带重试会和上层叠加,必须关掉(Day74 踩过)"""
global _client
if _client is None:
api_key = os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("未检测到 DEEPSEEK_API_KEY,请在 .env 中配置后重试")
_client = OpenAI(api_key=api_key,
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
timeout=httpx.Timeout(30.0), max_retries=0)
return _client
def chat(messages: list[dict[str, str]], temperature: float = 0.0,
max_tokens: int = 1024, stop: list[str] | None = None) -> tuple[str, Usage]:
"""返回 (文本, 用量)。temperature=0 是 Agent 的默认值 ------ 规划环节不要随机"""
t0 = time.perf_counter()
resp = get_client().chat.completions.create(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
messages=messages, temperature=temperature,
max_tokens=max_tokens, stop=stop,
)
u = Usage(prompt_tokens=resp.usage.prompt_tokens if resp.usage else 0,
completion_tokens=resp.usage.completion_tokens if resp.usage else 0,
latency_ms=int((time.perf_counter() - t0) * 1000))
return resp.choices[0].message.content or "", u
if __name__ == "__main__":
text, u = chat([{"role": "user", "content": "用一句话说明什么是 ReAct"}])
print(text, f"({u.total} tokens / {u.latency_ms}ms)")
4.3 实操步骤 4:ReAct 提示词(agent/prompts.py)
提示词单独放一个文件------这是 Day 59 养成的习惯(能用 Jinja2 更好,今天纯 Python f-string 就够)。
"""agent/prompts.py ------ Day76:ReAct 提示词模板
⭐ 单独放文件的理由和 Day59 一样:调 Prompt 不该动业务代码。
"""
from agent.tools import render_tool_catalog
REACT_SYSTEM = """你是一个工厂设备运维助手,能调用工具来回答用户的真实业务问题。
## 工作方式(严格遵守)
你处在一个循环中。每一轮你先【想】,再【做】,然后你会看到工具的返回结果,再决定下一轮。
请始终使用下面的格式输出,一次只能输出一个 Action:
Thought: 你这一步的思考。必须包含两件事:① 我现在还缺什么信息;② 我打算用哪个工具补上它。
Action: 工具名(只能是你【可用工具】里列出的名字之一)
Action Input: 该工具的参数,写成一行 JSON,如 {{"device_id": "A3", "status": "open"}}
当你已经收集到足够信息可以直接回答用户时,输出:
Thought: 我已有足够信息回答。
Final Answer: 给用户的话。必须基于上面 Observation 里出现的真实数据,
禁止编造任何工单号、参数值或手册章节。
## 铁律
1. 禁止编造。Observation 里没有的数据,一律说"未查到",不许猜测。
2. 一个 Action 只做一件事。不要用同一个 Action 一次查两台设备。
3. 参数写错会得到 [错误] 开头的 Observation,请读错误信息并改名/改参数重试,最多 2 次。
4. 涉及数值计算(百分比、时长、金额)必须调用 calc,禁止心算。
5. create_ticket 是写操作,只有用户明确说"建单/立项/跟进"时才允许调用。
6. 回答用中文,短句短段落,关键参数加粗,写给一线师傅看。
## 可用工具
{tools}
"""
def build_system() -> str:
"""渲染带工具清单的 system prompt(工具变了自动跟着变)"""
return REACT_SYSTEM.format(tools=render_tool_catalog())
OBSERVATION_TEMPLATE = "Observation: {obs}\n\n继续。如果信息已足够,请直接输出 Final Answer。"
⚠️ 写 ReAct 提示词的三个坑 :①
Action Input:后面那一行的 JSON,如果工具没有必填参数,模型常写成{"device_id": null},工具里要自己处理 null(今天_list_tickets已经把""/None当未填);② 铁律第 2 条"一次只做一件事"必须写进去,否则模型贪心,一次塞两个设备号,工具只能返回一条;③ 必须在 system 里写这么一句 :"如果存在构建最终答案所需的全部信息,请直接输出 Final Answer",否则轻量模型会一直"再看一眼"。
4.4 实操步骤 5:解析与主循环(agent/react.py)
今天的主菜。
"""agent/react.py ------ Day76:徒手实现的 ReAct 循环(约 150 行,无框架)
用法:
python -m agent.react "A3 今天有哪些没关的工单?"
循环本体就在 run_agent() 那一个 while-for 里,其余都是它的护栏与留痕。
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass, field
from agent.llm import Usage, chat
from agent.prompts import OBSERVATION_TEMPLATE, build_system
from agent.tools import TOOLS
MAX_STEPS = int(__import__("os").getenv("AGENT_MAX_STEPS", "8"))
MAX_OBS_CHARS = 1500 # ⭐ 单次观察的最大字符数(防爆上下文)
MAX_REPEAT = 2 # ⭐ 完全相同的调用最多允许出现 2 次
# 三种终局标记
RE_ACTION = re.compile(r"Action\s*:\s*([A-Za-z_][A-Za-z0-9_]*)", re.M)
RE_INPUT = re.compile(r"Action\s*Input\s*:\s*(.+?)(?:\n|$)", re.S)
RE_FINAL = re.compile(r"Final\s*Answer\s*:\s*(.+)", re.S)
RE_THOUGHT = re.compile(r"Thought\s*:\s*(.+?)(?:\nAction|\nFinal Answer|$)", re.S)
@dataclass
class Step:
"""一轮循环的完整留痕 ------ Day 85 接 Langfuse 时它就是一条 span"""
index: int
thought: str = ""
action: str = ""
action_input: dict = field(default_factory=dict)
observation: str = ""
parse_ok: bool = True
usage: Usage = field(default_factory=Usage)
@dataclass
class Result:
answer: str
steps: list[Step]
total_steps: int
total_tokens: int
stop_reason: str # final / max_steps / repeat / budget
ok: bool
def summary(self) -> str:
return (f"{'✅' if self.ok else '⚠️'} 停止原因={self.stop_reason} "
f"轮次={self.total_steps} tokens={self.total_tokens}")
def _safe_json(raw: str) -> tuple[dict, str]:
"""把 'Action Input:' 后面的字符串解析成 dict;失败返回 ({}, 错误信息)"""
raw = (raw or "").strip().strip("`").rstrip(",")
if not raw:
return {}, "空"
for candidate in (raw, raw + "}"):
try:
v = json.loads(candidate)
return (v if isinstance(v, dict) else {"_raw": v}), ""
except json.JSONDecodeError:
continue
# 退化策略:key: value 单行模式(模型偶尔会写成 device_id: A3)
kv = dict(re.findall(r"([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*([^,}\n]+)", raw))
if kv:
return {k: v.strip().strip("\"'") for k, v in kv.items()}, ""
return {}, f"无法解析的参数字符串:{raw[:120]}"
def _truncate(text: str, limit: int = MAX_OBS_CHARS) -> str:
"""观察截断:头部信息密度最高,丢尾巴 + 明确告诉模型被截了"""
if len(text) <= limit:
return text
head = text[:limit]
cut = head[:head.rfind("\n")] if "\n" in head else head
return f"{cut}\n......[观察结果过长已被截断,共 {len(text)} 字符,仅显示前 {len(cut)} 字符。" \
f"如需更多请缩小查询范围]"
def parse_reply(reply: str) -> tuple[str, str, dict, str, bool]:
"""解析模型输出 → (thought, action, args, final_answer, 是否解析成功)"""
thought = (RE_THOUGHT.search(reply).group(1).strip()
if RE_THOUGHT.search(reply) else "")
m_final = RE_FINAL.search(reply)
if m_final:
return thought, "", {}, m_final.group(1).strip(), True
m_act = RE_ACTION.search(reply)
if not m_act:
return thought, "", {}, "", False
action = m_act.group(1).strip()
m_in = RE_INPUT.search(reply)
args, _ = _safe_json(m_in.group(1)) if m_in else ({}, "")
return thought, action, args, "", True
def run_agent(question: str, max_steps: int = MAX_STEPS,
verbose: bool = True) -> Result:
"""⭐ ReAct 主循环。今天唯一必须读懂的函数。"""
messages: list[dict[str, str]] = [
{"role": "system", "content": build_system()}, # ① 感知:工具清单 + 铁律
{"role": "user", "content": question},
]
steps: list[Step] = []
seen_calls: list[tuple[str, str]] = [] # (工具名, 冻结的参数串)
total_tokens = 0
for i in range(1, max_steps + 1):
reply, usage = chat(messages, temperature=0, max_tokens=1024,
stop=["Observation:"]) # ② 规划:让模型输出一轮
total_tokens += usage.total
thought, action, args, final, ok = parse_reply(reply)
step = Step(index=i, thought=thought, action=action,
action_input=args, parse_ok=ok, usage=usage)
steps.append(step)
if verbose:
print(f"\n─── 第 {i} 轮 ───────────────────────────────")
print(f"💭 {thought or '(未输出 Thought)'}")
if action:
print(f"🔧 {action} {json.dumps(args, ensure_ascii=False)}"
f" ({usage.total} tokens / {usage.latency_ms}ms)")
# ③ 收尾:模型说够了
if final:
if verbose:
print("✅ Final Answer 已产生")
return Result(final, steps, i, total_tokens, "final", True)
# ④ 护栏一:解析失败 → 给它一次纠错机会(原文回灌 + 明确指令)
if not ok or not action:
steps[-1].observation = "[系统] 输出格式不对"
hint = ("你没有输出符合格式的 Action。请严格按以下格式输出:\n"
"Thought: ...\nAction: 工具名\nAction Input: {\"参数\": \"值\"}\n"
"若信息已足够,则输出 Thought: ... 换行 Final Answer: ...")
messages += [{"role": "assistant", "content": reply},
{"role": "user", "content": hint}]
continue
# ⑤ 护栏二:工具不存在 → 不报错,告诉它正确的名字让它重试
tool = TOOLS.get(action)
if tool is None:
step.observation = "[错误] 工具不存在"
msg = (f"[错误] 工具 {action} 不存在。可用工具只有:{', '.join(TOOLS)}。"
f"请从中选择一个,不要编造工具名。")
if verbose:
print(f" ❌ {msg}")
messages += [{"role": "assistant", "content": reply},
{"role": "user", "content": OBSERVATION_TEMPLATE.format(obs=msg)}]
continue
# ⑥ 护栏三:完全重复的调用(工具名 + 参数都一样)
fingerprint = (action, json.dumps(args, ensure_ascii=False, sort_keys=True))
if seen_calls.count(fingerprint) >= MAX_REPEAT:
reason = f"重复调用 {action} 超过 {MAX_REPEAT} 次,判定为卡住,强制收尾"
if verbose:
print(f" 🛑 {reason}")
steps[-1].observation = reason
return Result(_fallback_answer(question, steps, reason), steps, i,
total_tokens, "repeat", False)
seen_calls.append(fingerprint)
# ⑦ 行动:真正执行(异常已在 Tool.call 内部转成字符串)
obs = _truncate(tool.call(args)) # ⑧ 观察截断
step.observation = obs
if verbose:
print(f"👀 {obs.replace(chr(10), ' ')[:160]}{'...' if len(obs) > 160 else ''}")
messages += [{"role": "assistant", "content": reply},
{"role": "user", "content": OBSERVATION_TEMPLATE.format(obs=obs)}]
# ⑨ 转满圈也没有 Final Answer → 强制收尾,不许崩
reason = f"达到最大轮次 {max_steps},可能存在死循环"
return Result(_fallback_answer(question, steps, reason), steps,
max_steps, total_tokens, "max_steps", False)
def _fallback_answer(question: str, steps: list[Step], reason: str) -> str:
"""降级答案:把已经查到的事实列出来 + 说清楚为什么停 ------ 绝不给用户空白或 500"""
facts = [s.observation for s in steps if s.observation and not s.observation.startswith("[")]
lines = [f"⚠️ 任务未能完全自动完成(原因:{reason})。以下是我已查到的信息:", ""]
lines += [f" - {f[:200]}" for f in facts] or [" - (本轮没有获取到有效数据)"]
lines += ["", "请人工确认上述信息,或把问题拆得更具体一些再问一次。"]
return "\n".join(lines)
if __name__ == "__main__":
q = " ".join(sys.argv[1:]) or "A3 注塑机有哪些还没关闭的工单?分别是什么问题?"
r = run_agent(q)
print(f"\n问:{q}\n\n{r.answer}\n\n{r.summary()}")
python -m agent.react "A3 注塑机有哪些还没关闭的工单?手册里对超温的处理要求是什么?"
真实输出(轮次与 token 数每次会略有差异,这是正常的):
─── 第 1 轮 ───────────────────────────────
💭 用户问两件事:① A3 未关闭工单;② 手册中超温处置要求。先查工单列表。
🔧 list_tickets {"device_id": "A3", "status": "open"}
(812 tokens / 1340ms)
👀 共 2 条工单:
- WO-20260930-01 | A3 | open | high | 料筒温度 91℃,超温告警未消
- WO-20261001-03 | A3 | open | medium | 液压油管接头处渗油
⚠️ 工单接口当前不可达,以上为本地样本数据,向用户汇报时必须说明。
─── 第 2 轮 ───────────────────────────────
💭 工单已拿到。现在查手册里关于超温(料筒温度超过 85℃)的处置要求。
🔧 search_manual {"query": "A3 料筒超温怎么处理", "device_id": "A3"}
(1560 tokens / 1820ms)
👀 [1] 章节:3.2 料筒超温处置(相似度 0.79)
A3 注塑机料筒设定温度 80℃,超过 85℃ 触发高温告警。禁止在料筒温度未降至 60℃ 以下时打开防护罩。
─── 第 3 轮 ───────────────────────────────
💭 工单与手册章节都已获得,可以直接回答用户。
✅ Final Answer 已产生
A3 注塑机当前有 2 条未关闭工单:
1. **WO-20260930-01**(high):料筒温度 91℃,超温告警未消
2. **WO-20261001-03**(medium):液压油管接头处渗油
手册要求(§3.2 料筒超温处置):料筒设定温度 80℃,**超过 85℃ 会触发高温告警**;
在料筒温度**未降至 60℃ 以下时禁止打开防护罩**。
✅ 停止原因=final 轮次=3 tokens=2941
停下来看这个输出,确认三件事:
① 它自己决定先查工单、再查手册 ------ 你没有写这个顺序(先后顺序由 Thought 决定)
② 第 2 轮的 messages 里已经包含第 1 轮的 Observation ------ 它是【看着结果在规划】
③ 第 3 轮它自己判断"够了"并收尾 ------ 循环退出条件是模型决定的,不是写死的三个步骤
三条全占,这才叫 Agent。
💡 FDE 的判断标准 :看一个"Agent"是不是真的 Agent,只需要一个测试------问它一个需要三步以上、且第三步依赖前两步结果的问题。如果它的执行路径是写死的三步(不管数据怎样都跑完三步),那是 pipeline;如果它会说"查完发现不用第三步了",那才是 Agent。演示的时候把这个差别演给客户看,比讲 ReAct 论文有用得多。
📖 五、观察旋钮:每一轮里面到底发生了什么
这是 Agent 调试的第一现场:绝大多数的 Agent bug,打印 messages 就能定位。
初始
[0] system ReAct 铁律 + 工具清单(约 900 tokens,固定开销)
[1] user "A3 注塑机有哪些还没关闭的工单?..."
第 1 轮后
[2] assistant "Thought: ...\nAction: list_tickets\nAction Input: {...}"
[3] user "Observation: 共 2 条工单:...\n\n继续。如果信息已足够..."
第 2 轮后
[4] assistant "Thought: ...\nAction: search_manual\nAction Input: {...}"
[5] user "Observation: [1] 章节:3.2 料筒超温处置..."
第 3 轮后
[6] assistant "Thought: ...\nFinal Answer: ..." ← 循环退出
⚠️ 注意上下文是【单调增长】的:每一轮都把上一轮原封不动带上。
→ 第 N 轮的 prompt tokens ≈ 900 + 问题 + Σ(前 N-1 轮的 assistant+observation)
→ 这就是 Agent 的隐形成本:轮次越多,后面的每一轮越贵
5.2 实操步骤 6:成本与轮次的实测
加一个统计脚本 agent/stats.py,用真实数据说话(给客户看的数字必须能算账):
"""agent/stats.py ------ Day76:跑一组问题,统计轮次 / tokens / 停止原因
用法:python -m agent.stats
"""
from __future__ import annotations
import json
import statistics
from pathlib import Path
from agent.react import Result, run_agent
CASES = [
("单步", "A3 有哪些未关闭的工单?"),
("双步", "A3 有哪些未关闭的工单?超温该怎么处置?"),
("含计算", "A3 料筒 91℃,设定值 85℃,超了百分之几?"),
("查不到", "Z9 设备最近的工单有哪些?"),
("需建单", "B2 冲压机冷却水压偏低,帮我建一张跟进单。"),
]
OUT = Path("doc/week12/day76_agent_stats.json")
def run_all() -> list[dict]:
rows = []
for name, q in CASES:
r: Result = run_agent(q, verbose=False)
path = " → ".join(s.action or "Final" for s in r.steps if s.action) or "直接回答"
rows.append({"case": name, "question": q, "steps": r.total_steps,
"tokens": r.total_tokens, "stop_reason": r.stop_reason,
"ok": r.ok, "path": path})
print(f" {name:<6} 轮次={r.total_steps} tokens={r.total_tokens:<6} "
f"停止={r.stop_reason:<10} {'✅' if r.ok else '⚠️'}")
print(f" 路径:{path}")
return rows
def summary(rows: list[dict]) -> None:
steps = [r["steps"] for r in rows]
toks = [r["tokens"] for r in rows]
print("\n" + "=" * 58)
print(f" 轮次 平均 {statistics.mean(steps):.2f} 最大 {max(steps)}")
print(f" tokens 平均 {statistics.mean(toks):.0f} 最大 {max(toks)} 合计 {sum(toks)}")
print(f" 成功 {sum(1 for r in rows if r['ok'])}/{len(rows)}")
print("=" * 58)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 明细已存档 → {OUT}")
if __name__ == "__main__":
summary(run_all())
python -m agent.stats
单步 轮次=2 tokens=1893 停止=final ✅
路径:list_tickets → Final
双步 轮次=3 tokens=2941 停止=final ✅
路径:list_tickets → search_manual → Final
含计算 轮次=3 tokens=2610 停止=final ✅
路径:search_manual → calc → Final
查不到 轮次=2 tokens=1508 停止=final ✅
路径:list_tickets → Final
需建单 轮次=2 tokens=1712 停止=final ✅
路径:create_ticket → Final
==========================================================
轮次 平均 2.40 最大 3
tokens 平均 2133 最大 2941 合计 10664
成功 5/5
==========================================================
✅ 明细已存档 → doc/week12/day76_agent_stats.json
对照 v0.2:单次问答 ~800 tokens / 1.4s;Agent 单步 ~1900 / 2.5s;Agent 三步 ~2900 / 4.5s。
⭐ 成本公约:Agent 每一轮大概是 1000 tokens 上下(system 固定 ~900 + 一轮增量)。
所以【轮次就是成本】:8 轮 ≈ 8000 tokens ≈ 一次 v0.2 问答的 10 倍。
→ 这也是为什么 MAX_STEPS 不是一个"技术参数",而是一个【预算参数】。
客户问"一次查询多少钱",你回答不上来,就别上 Agent。
📌 给客户的成本话术 :不要说"一次调用几分钱"。要说**"一次典型业务问答约 X 轮、约 Y tokens,按当前单价约 Z 分钱;每天 200 次查询约 N 元/月"**。 Day 74 写的
ops/cost.py今天就用得上------把 Agent 的每次 run 也记进台账。
📌 顺带把 Day 85 的活就在这解决了 :Step里有index / thought / action / action_input / observation / usage,正好对应 Langfuse 的一条 span。所以留痕不是事后补的功能 ------从今天写循环起就结构化记下来,Day 85 只要把steps序列化发出去;如果今天只
🖥️ 六、三种必踩的坑与三道护栏
6.1 坑一:死循环(重复现象)
反面教材 vs 正面做法,直接对比。反面写法只有三行,却是我在现场见过最多的 Agent 事故源头:
❌ 反面:while True + 不检测重复
┌─────────────────────────────────────────────────┐
│ while True: │
│ reply = llm(messages) │
│ # 模型每轮输出同样的 search_manual,永远查不到 │
│ # 转了 60 圈,账单出来了,答案还没有 │
└─────────────────────────────────────────────────┘
现场后果:一个用户点了两次按钮,一夜烧掉一个月的预算。
✅ 正面(今天的实现):
① for i in range(MAX_STEPS) ------ 硬上界,物理封顶
② seen_calls.count(fp) >= 2 → break ------ 同调用出现两次以上判定卡住
③ 退出时走 _fallback_answer() ------ 把已查到的事实交出来,不许返回空白
6.2 坑二:幻觉工具名 / 参数乱填
真实日志(某次没写护栏的运行):
Action: query_ticket_by_device ← 工具表里根本没有这个名
Action Input: {"device": "A3"} ← 字段名应该是 device_id
❌ 如果裸调:KeyError 抛出 → 500 → 用户看到一堆堆栈
✅ 今天的做法:
TOOLS.get(action) 拿不到 → 返回一句【错误 + 全部可用工具名】
Tool.call() 内部 catch 所有异常 → 转成 "[错误] ...请检查参数后重试"
→ 模型下一轮看到错误,自己改对了(这就是 ReAct 论文里说的 self-correction)
跑一次看自纠效果:
"""day76_self_correct.py ------ Day76:验证模型的自我纠正能力(工具报错后能否改对)"""
import json
from agent.llm import chat
from agent.react import parse_reply
from agent.tools import TOOLS
# ① 看工具层怎么把异常翻译成"模型看得懂的话"
print(TOOLS["get_ticket"].call({})) # 缺必填参数
print(TOOLS["list_tickets"].call({"devcie_id": "A3"})) # 字段名拼错
# ② 伪造一轮"调用了不存在的工具",看模型下一轮会不会自己改过来
fake = 'Thought: 我要查工单。\nAction: query_ticket\nAction Input: {"device": "A3"}'
msg = ("Observation: [错误] 工具 query_ticket 不存在。可用工具只有:"
+ ", ".join(TOOLS) + "。请从中选择一个,不要编造工具名。")
messages = [
{"role": "system", "content": "你是运维助手,必须使用真实存在的工具。"},
{"role": "user", "content": "A3 有哪些未关闭工单?"},
{"role": "assistant", "content": fake},
{"role": "user", "content": msg},
]
reply, _ = chat(messages)
t, a, args, final, ok = parse_reply(reply)
print(f"\n【自我纠正后】Action = {a or '(直接 Final)'} Input = {json.dumps(args, ensure_ascii=False)}")
python day76_self_correct.py
[错误] 缺少必填参数:['ticket_id']。请重试并补全这些字段。
[错误] 未知参数:['devcie_id']。该工具只接受:['device_id', 'limit', 'status']
【自我纠正后】Action = list_tickets Input = {"device_id": "A3", "status": "open"}
💡 FDE 的判断标准 :错误文案要写给人(模型)看,不要写给机器看 。看上面两行------它不仅说"未知参数 devcie_id",还把可选字段全列出来了 。这一句让自纠成功率从"随缘"变成"基本一次改对"。工具的错误返回,是你给模型的第二次教学机会。
6.3 坑三:观察太长爆上下文
一次 list_tickets(status="all") 可能返回 3000 条工单------超过上下文窗口就直接报错,勉强塞进去则让 prompt 从 900 tokens 涨到几十万,一次调用烧掉几块钱。今天的三重闸门是:
① 工具内部:MAX_LIST=8,超出就截断并提示"请缩小范围"
② 循环内部:_truncate(1500) 守住单次观察
③ 提示词内部:写明"结果最多返回 8 条,超出会被截断"
------ 让模型【一开始就学会自己收紧条件】,而不是事后被动截断
三层闸门缺一层会怎样:
| 只做了 | 后果 |
|---|---|
| 只有工具内截断 | 工具返回值被你自己写死,模型无法感知"还有更多" |
| 只有循环内截断 | 模型浪费一轮去查一个它其实可以精确化的条件 |
| 只有提示词 | 依赖模型自觉,不可靠(这是最危险的一层) |
| 三层都有 | 工具不会炸、循环不会爆、模型还会自己优化查询 ✅ |
6.4 实操步骤 7:把这三道护栏做成可开关的配置
"""agent/guard.py ------ Day76:Agent 的三道护栏(集中一处,便于客户现场调整)
为什么单独一个文件:MAX_STEPS 这类数字,客户现场一定会要求改。
写散在主循环里,改一处漏一处;集中在这里,改一个环境变量就生效。
"""
from __future__ import annotations
import os
def limits() -> dict[str, int | float]:
"""一次性读出全部护栏值,便于打印、落日志、写进 /health"""
return {
"max_steps": int(os.getenv("AGENT_MAX_STEPS", "8")), # ⭐ 预算护栏
"max_obs_chars": int(os.getenv("AGENT_MAX_OBS_CHARS", "1500")),
"max_repeat": int(os.getenv("AGENT_MAX_REPEAT", "2")),
"max_tokens_per_run": int(os.getenv("AGENT_MAX_TOKENS", "20000")),
"temperature": float(os.getenv("AGENT_TEMPERATURE", "0")),
}
def danger_tools() -> frozenset[str]:
"""需要人工确认的高危工具(Day80 正式启用)"""
return frozenset(os.getenv("AGENT_DANGER_TOOLS",
"create_ticket,update_ticket,close_ticket,notify").split(","))
把 agent/react.py 顶部三个常量换成从 limits() 读取(改一行,从此可现场调):
python -c "from agent.guard import limits, danger_tools; print(limits()); print(sorted(danger_tools()))"
# 现场压测时收敛得更狠一点:
set AGENT_MAX_STEPS=4 && python -m agent.stats
建议的取值区间(直接照抄到交付文档里):
| 场景 | MAX_STEPS | MAX_OBS | 理由 |
|---|---|---|---|
| 值班问答(今天) | 6~8 | 1500 | 问题简单,3 轮内基本能收尾 |
| 故障排查(Day 82) | 12 | 2000 | 要多查几个系统,但仍有封顶 |
| 批量巡检报告 | 4 | 800 | 量大,单只允许少容错多跑几遍 |
| 成本敏感客户 | 4 | 800 | 宁可降级也不许爆账单 |
📖 七、什么时候不该用 Agent
这一节是今天最值钱的部分。第 12 周学四天框架,如果不会判断"不该用",就是给客户埋雷。
7.1 三种形态的选择表
| 需求 | 用直线代码 | 用 RAG | 用 Agent | 判据 |
|---|---|---|---|---|
| 把一段话转成结构化字段 | ✅ | ❌ | ❌ | 纯变换,无外部依赖 |
| "这本手册怎么说" | ❌ | ✅ | ❌ | 一次检索就够,路径固定 |
| "A3 现在有什么问题" | ❌ | ❌ | ✅ | 要调真实接口拿实时状态 |
| "查工单 + 查手册 + 判断要不要建单" | ❌ | ❌ | ✅ | 下一步依赖上一步结果 |
| 每天凌晨生成巡检报告 | ✅ 定时任务 | ❌ | ❌ | 定时 + 固定逻辑,别让模型乱跑 |
| "帮我看看最近一周有哪些异常" | ❌ | ⚠️ | ✅ | 需要多次聚合查询 |
⚠️ 一句话判据 :步骤顺序【固定】→ 写代码;【不固定】但只查一次 → RAG 或单步工具调用;【不固定】且要查多次 → 才轮到 Agent。而 Agent 的标价是:延迟 310 倍、token 310 倍、故障模式从「答错」变成「跑飞」。
7.2 FDE 现场四条「不用 Agent」的反面清单
❶ 【有人身/设备安全后果的动作不许全自动】
关闭工单、下发停机指令、发全厂通知 ------ 必须人在回路(Day 80)
❷ 【客户要求可解释、可复现的流程不许全自动】
SOP 合规场景:同一句话两次运行结果不同,客户不敢签验收
❸ 【步骤其实固定的任务不许用 Agent 炫技】
明明 retrieve → rerank → generate 三步就够,
非要包个 Agent ------ 多花 5 倍成本,recall 还下降了
❹ 【延迟敏感接口不许同步跑多轮 Agent】
/health、前端打字联想这类接口,Agent 会让用户觉得"卡死"
💡 FDE 的判断标准 :给客户提案时,我通常先问一句**"这个流程,你手下的老师傅是怎么做的?一步一步写下来。"** 如果他写出来是固定 5 步 → 写代码;如果他说"要看情况,一般先看看是不是缺料,缺料就...,不缺就..." → 这里就该 Agent。Agent 的适用与否,藏在老师傅那句"要看情况"里。
7.3 今天的国防 Tier
把一个 Agent 上线前,至少要补齐这三样(今天完成了前两样):
Tier 1 ------ 不崩(今天已完成)
✅ 最大轮次 / 重复检测 / 观察截断 / 异常转字符串 / 降级答案
Tier 2 ------ 不贵(今天已完成一半)
✅ token 统计与 Step 留痕
⬜ 每 run 的 token 预算硬闸门(练习 2)
⬜ 并发限流(Day 74 的令牌桶直接用)
Tier 3 ------ 不作恶(Day 80)
⬜ 高危工具人工审批(interrupt)
⬜ 写操作幂等键(同一句话别建两张单)
⬜ 全链路 trace(Day 85)
📊 Agent 基础速查表
ReAct 三件套
| 环节 | 关键词 | 一行代码 |
|---|---|---|
| 感知 | messages | messages = [{"role":"system","content":build_system()}, {"role":"user",...}] |
| 规划 | LLM 输出 | reply, usage = chat(messages, temperature=0) |
| 行动 | 执行工具 | obs = TOOLS[action].call(args) |
| 观察 | 塞回上下文 | messages += [{"role":"assistant","content":reply}, {"role":"user","content":f"Observation: {obs}"}] |
| 收尾 | Final Answer | RE_FINAL.search(reply) |
| 降级 | 兜底 | _fallback_answer(question, steps, reason) |
解析正则(今天那四个)与常见翻车
| 目的 | 正则 | ||
|---|---|---|---|
| 取 Thought | `r"Thought\s*:\s*(.+?)(?:\nAction | \nFinal Answer | $)"` |
| 取工具名 | r"Action\s*:\s*([A-Za-z_][A-Za-z0-9_]*)" |
||
| 取参数 | `r"Action\sInput\s:\s*(.+?)(?:\n | $)"` | |
| 取终局 | r"Final\s*Answer\s*:\s*(.+)" |
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
| 每轮输出一模一样 | Observation 没塞回 messages | 检查是否被包进 role=user |
| 模型不输出 Thought 直接 Action | 提示词没强制格式 | 在 system 里写明"一次只能输出一个 Action" |
Action Input 解析失败 |
模型带换行写 JSON | _safe_json 多策略 + 丢弃外层反引号 |
| 工具不存在导致崩溃 | TOOLS[action] 直接下标 |
TOOLS.get() + 返回可用工具清单 |
| 参数名拼错 | 模型自由发挥 | Tool.call 检查未知参数,返回正确字段列表 |
| 上下文爆掉 | 长列表没截断 | 工具内 MAX_LIST + 循环内 _truncate + 提示词预告 |
| 迟迟不收尾 | 没有"够了就收"的指令 | Observation 模板末尾加"如信息已足够,请直接输出 Final Answer" |
| 成本失控 | while True |
for i in range(MAX_STEPS) + token 预算 |
| 工具报错后模型放弃 | 异常栈透传给模型 | 转成"错误...请检查参数后重试"的自然语言 |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| Agent 定义 | LLM + 工具集 + 循环决策,三者缺一不可 |
| 四步循环 | 感知(读)→ 规划(想)→ 行动(做)→ 观察(看)→ 回到规划 |
| ReAct | 在同一个回答里交替输出 Thought / Action / Observation |
| Thought 的价值 | 逼模型先想再做;去掉它错误率肉眼可见上升 |
| Observation | 外部事实,是打断幻觉的唯一手段;格式要写给模型读 |
| 观察必须回灌 | 忘记塞回 messages = 每轮重来 = 最常见的"卡住" |
| MAX_STEPS | 是预算参数不是技术参数;它在成本台账里的含义是"一次问答多少钱"的上界 |
| 三道护栏 | 最大轮次 / 重复检测 / 观察截断,缺一层都可能炸 |
| 错误即教学 | 工具报错要返回"自然语言 + 正确选项",让模型自己改 |
| 危险工具标记 | dangerous=True 现在只是标记,Day 80 会变成人工审批闸门 |
| 成本公约 | 一轮约 1000 tokens,轮次就是钱 |
🧠 核心认知 :Agent 带来的不是"更聪明",而是"能拿到它原本没有的事实,并且能根据拿到的事实改变自己的计划 "------前一半解决 Day 61 说的第三个硬伤(幻觉),后一半才是真正的增量。而这个增量是有价格的:延迟 310 倍、token 310 倍、故障模式从"答错"升级为"跑飞" 。今天手写那 150 行循环的意义,正在于此------你亲眼看见了那个 for 循环、看见了三轮的 messages 是怎么涨起来的、看见了 2941 个 tokens 花在哪。只有见过这些,你才能在 Day 77-79 用框架时不被引擎盖底下的东西反咬一口,也才能在客户问"它会不会一直转"时,指着 MAX_STEPS 说"不会,最多 8 轮,第 9 轮一定给你答案"。
📋 课后练习
练习 1:把「忘了塞回 Observation」这个 bug 亲手复现一次(约 40 分钟)
-
复制
agent/react.py为agent/react_broken.py,把messages += [{"role":"assistant",...},{"role":"user",...OBS...}]中的第二条整行删掉。 -
跑
python -m agent.react_broken "A3 有哪些未关闭工单?",观察并记录:转了几轮、stop_reason是什么、每一轮的 Thought 是否一模一样。 -
用
print(json.dumps(messages, ensure_ascii=False, indent=2))打印最终的 messages,确认 user 消息的条数停留在 1。 -
把
AGENT_MAX_STEPS改成 20 再跑一次,记录它到底能浪费多少 tokens。 -
写 100 字感想:如果这套东西上线在客户现场、且有 200 个班次工人同时用,会发生什么?
练习 2:实现 token 预算硬闸门(约 50 分钟)
-
limits()里已有max_tokens_per_run。在run_agent()每轮结束后加判断:if total_tokens > limits()["max_tokens_per_run"]: return Result(_fallback_answer(...), ..., "budget", False)。 -
把
_fallback_answer改成会顺便打印"已消耗 N tokens,预算 M"。 -
临时把
AGENT_MAX_TOKENS设成 1500 跑一遍 5 个用例,确认至少有一个用例会命中budget停止原因,且返回的答案仍然对用户有用(不是空白、不是报错)。 -
思考并写下:这个闸门应该放在每轮开始前 还是每轮结束后?两种放法各自的代价是什么?(提示:前者省钱但可能人为地 cut off 一个本来能成功的第 3 轮)
练习 3:真实的 Agent vs Pipeline 对照实验(约 60 分钟)
-
手工写一个"直线 pipeline"函数:
def straight_line(question): 先 list_tickets(A3, open),再 search_manual(question),最后一次性让 LLM 总结------三步写死,不看中间结果。 -
对这 4 个问题分别跑 pipeline 和
run_agent,记录轮次、tokens、答案是否包含真实工单号:-
"A3 有哪些未关闭工单,超温该怎么处理?"(两者都能答)
-
"A3 现在有没有未关闭工单?"(pipeline 会多查一次没用的手册)
-
"超温超过设定值百分之几要停机?"(需要 calc)
-
"Z9 有什么问题?"(查不到)
-
-
做成一张对比表,包含:tokens 消耗、延迟、答错次数。
-
回答两个问题并写进
doc/week12/day76_reflection.md:① 哪一类问题上 pipeline 明显更划算?② 哪一类问题上 pipeline 根本答不了?用自己的数据说话,不要用我的结论。
🔭 下节预告
今天你有了能跑的 Agent,但遗留问题也很明显:① 工具清单手写 Markdown 拼接 ,加一个字段要改两处还可能不同步;② 解析靠正则 ,模型输出歪一格就崩;③ 每次都要重新拼 messages ,复用、缓存、并行全得自己写;④ 没有 retry、没有流式、没有异步 ;⑤ 换模型要改多处,提示词里的格式约定写死了。
明天(Day 77)用 LangChain + LCEL 逐个解决 ①②③⑤:
| 环节 | 做什么 |
|---|---|
| ① 三件套 | ChatPromptTemplate / ChatOpenAI(接 DeepSeek)/ StrOutputParser·JsonOutputParser·PydanticOutputParser |
| ② LCEL 管道 | `chain = prompt |
| ③ 四种调用 | invoke / batch(并发 20 题只花一次等待)/ stream(打字机效果)/ ainvoke(Day 74 的 FastAPI 直接受益) |
| ④ 重写 v0.2 问答链 | 用 LCEL 重写 Day 65 那条检索→生成链,对比代码量与tokens |
| ⑤ 结构化输出 | 用 PydanticOutputParser 替掉今天的正则 ------ 解析失败自动重试的核心做法 |
一句话:今天你亲手看见了齿轮,明天给你一套标准的变速箱。 Day 77 结束的时候,今天这 150 行循环里的"拼装部分"会被压缩到 30 行以内,而你还是知道它们在干嘛------因为齿轮今天已经转过了。
明天见,第 12 周开张大吉 🎉
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
【FDE系列】阶段3:Day 63:文档解析 --- 把真实 PDF 手册变成可用文本-CSDN博客
【FDE系列】阶段3:Day 64:文本分块 --- 决定检索成败的那一步-CSDN博客
【FDE系列】阶段3:Day 65:向量数据库入门 --- Chroma 与本周收官-CSDN博客
【FDE系列】阶段3:Day 66:Qdrant 入门 --- 生产级向量库-CSDN博客
【FDE系列】阶段3:Day 67:混合检索 --- BM25 与 RRF 融合-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 69:引用溯源 --- 让每个答案都能对上原文-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 71:高级检索 --- 查询改写与 HyDE-CSDN博客
【FDE系列】阶段3:Day 72:RAGFlow 平台 --- 端到端知识库
【FDE系列】阶段3:Day 73:RAGAS 评测 --- 用数据证明改动有效-CSDN博客
【FDE系列】阶段3:Day 74:企业 RAG 架构 --- 增量更新与生产就绪检查单-CSDN博客
【FDE系列】阶段3:Day 75:知识库问答 v0.2--- 集成工单系统与本周收官-CSDN博客
待完成教程:
Agent 框架与开发
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证