【FDE系列】阶段3:Day 76:Agent 本质 — 徒手实现 ReAct 循环

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段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 序列化发出去;如果今天只 print 了,Day 85 你得把整个循环重写一遍。


🖥️ 六、三种必踩的坑与三道护栏

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 分钟)

  1. 复制 agent/react.py 为 agent/react_broken.py,把 messages += [{"role":"assistant",...},{"role":"user",...OBS...}] 中的第二条整行删掉。

  2. 跑 python -m agent.react_broken "A3 有哪些未关闭工单?",观察并记录:转了几轮、stop_reason 是什么、每一轮的 Thought 是否一模一样。

  3. 用 print(json.dumps(messages, ensure_ascii=False, indent=2)) 打印最终的 messages,确认 user 消息的条数停留在 1。

  4. 把 AGENT_MAX_STEPS 改成 20 再跑一次,记录它到底能浪费多少 tokens。

  5. 写 100 字感想:如果这套东西上线在客户现场、且有 200 个班次工人同时用,会发生什么?

练习 2:实现 token 预算硬闸门(约 50 分钟)

  1. limits() 里已有 max_tokens_per_run。在 run_agent() 每轮结束后加判断:if total_tokens > limits()["max_tokens_per_run"]: return Result(_fallback_answer(...), ..., "budget", False)。

  2. 把 _fallback_answer 改成会顺便打印"已消耗 N tokens,预算 M"。

  3. 临时把 AGENT_MAX_TOKENS 设成 1500 跑一遍 5 个用例,确认至少有一个用例会命中 budget 停止原因,且返回的答案仍然对用户有用(不是空白、不是报错)。

  4. 思考并写下:这个闸门应该放在每轮开始前 还是每轮结束后?两种放法各自的代价是什么?(提示:前者省钱但可能人为地 cut off 一个本来能成功的第 3 轮)

练习 3:真实的 Agent vs Pipeline 对照实验(约 60 分钟)

  1. 手工写一个"直线 pipeline"函数:def straight_line(question): 先 list_tickets(A3, open),再 search_manual(question),最后一次性让 LLM 总结------三步写死,不看中间结果。

  2. 对这 4 个问题分别跑 pipeline 和 run_agent,记录轮次、tokens、答案是否包含真实工单号:

    • "A3 有哪些未关闭工单,超温该怎么处理?"(两者都能答)

    • "A3 现在有没有未关闭工单?"(pipeline 会多查一次没用的手册)

    • "超温超过设定值百分之几要停机?"(需要 calc)

    • "Z9 有什么问题?"(查不到)

  3. 做成一张对比表,包含:tokens 消耗、延迟、答错次数。

  4. 回答两个问题并写进 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 治理)

阶段五:行业实战与认证

相关推荐
梯度下降者1 小时前
python在哪些方面有应用,python可以用在哪些方面
人工智能·python·web开发·科学计算·应用领域
老金带你玩AI1 小时前
Kimi K3.1 Pro凌晨突袭!2.8万亿参数,终端跑分距Opus仅0.7分
人工智能
青藤云安全1 小时前
AI攻防备战报告(运营商篇)|攻击面 “3+1” 解析:通信关基面临的新型 AI 安全风险
人工智能·网络安全·ai
隔窗听雨眠1 小时前
从PHP2Shell回望:PHP内核漏洞的二十年演进与AI时代的攻防转折
开发语言·人工智能·php
hunteritself1 小时前
AI 挖出 PHP「致命漏洞」!欧洲政府连夜关站,日本首相紧急喊话
开发语言·人工智能·php
IT_陈寒1 小时前
SpringBoot性能优化完全指南
前端·人工智能·后端
lucas_AI1 小时前
你天天在用的 SKILL.md 换东家了:四大厂商同日把 Agent 基础设施捐进 Linux 基金会
agent
光锥智能1 小时前
引领新一代人机交互,科大讯飞全新发布星火语音基座大模型 SparkAudio 1.0
人工智能·人机交互·语音识别
神奇的代码在哪里1 小时前
MOSS-Image-SFT 开源解读:JSON 布局控制文字与排版
人工智能·开源·文生图·diffusers·moss-image