分类: agent

  • ReAct Inside —— 从 Message 到 State,看懂 AI Agent 的工作原理

    很多人第一次接触 ReAct(Reason + Act)时,会以为它只是在 Prompt 里加了 Thought / Action / Observation 三个字段。

    但实际上,ReAct 的核心并不是 Prompt 格式,而是 Agent 的状态机(State Machine)

    本文从工程实现的角度,讲清楚 ReAct 在 LLM 内部到底是怎么运转的,以及它和现代 Function Calling、Tool Calling 之间的关系。

    一、什么是 ReAct?

    ReAct(Reason + Act)出自 2022 年的论文《ReAct: Synergizing Reasoning and Acting in Language Models》,作者是 Shunyu Yao 等人,由普林斯顿大学与 Google Research 合作完成。

    它的核心思想其实很简单:

    让 LLM 在推理(Reason)的过程中,可以随时调用外部工具(Act),再拿工具返回的信息继续推理。

    打个比方。传统 LLM 像一个闭卷考试的学生,题目一给,凭脑子里记住的东西一口气把答案写完:

    User
        │
        ▼
    LLM
        │
        ▼
    Answer
    

    ReAct 则像一个开卷、还能上网查资料的学生。遇到不确定的地方,他会先想”我得查一下”,去翻书、查天气、算一笔账,拿到结果再接着往下写:

    User
        │
        ▼
    LLM
        │
    Thought      ← 我该做什么
        │
    Action       ← 我去查天气
        │
    Tool         ← 工具真正执行
        │
    Observation  ← 查到的结果
        │
    LLM
        │
    Thought      ← 根据结果继续想
        │
    Answer
    

    它最大的改变是:

    模型不再一次性吐出最终答案,而是可以”思考 → 执行 → 拿到反馈 → 再思考”。

    二、很多人最大的误解

    几乎所有入门文章都会画这样一张图:

    Thought
       ↓
    Action
       ↓
    Observation
    

    于是很多人得出两个结论:

    • Observation 是 Action 的一部分;
    • Thought、Action、Observation 都只是 Prompt 里的不同字段。

    这两个结论都不准确。

    要讲清楚,得先区分两个完全不同的概念:

    • Message(消息):Agent 和外界之间真正传递的东西,是通信协议。
    • State(状态):Agent 脑子里的内部状态,描述它”想到哪一步了”。

    后面几节,我们就顺着这两个概念把问题拆开。

    三、从 Message 的角度看 ReAct

    假设用户问了一个很日常的问题:

    上海今天适合跑步吗?

    在整个过程中,真正产生的 Message 是这几条:

    User Message                ← 用户:上海今天适合跑步吗?
            │
            ▼
    Assistant Message #1        ← 模型输出
            │
            ├── Thought          我得先查一下天气
            └── Action(weather)  调用 weather("Shanghai")
            │
            ▼
    Tool Message                ← 工具返回
            │
            └── Observation      26℃,湿度 90%,有雨
            │
            ▼
    Assistant Message #2        ← 模型再次输出
            │
            ├── Thought          下雨又潮湿,不太适合
            └── Final Answer     不太建议,今天有雨
    

    这里有两个关键点:

    • Thought 和 Action 通常在同一条 Assistant Message 里,它们是模型一次输出的两个部分。
    • Observation 不是模型输出的,它是 Tool 返回的一条独立 Message。

    也就是说,从 Message 的层面看,参与对话的只有三类角色:User、Assistant、Tool。

    四、为什么 Observation 必须独立成一条消息?

    先说一个容易混淆的点:从内容上看,Observation 确实就是 Action 的返回值。

    比如模型发出动作:

    Action: weather("Shanghai")
    

    工具执行后返回:

    26℃
    Humidity: 90%
    Rain: true
    

    这段返回,就是 Observation。

    那既然内容上是一回事,论文为什么还要把 Observation 单独拎出来?

    关键不在内容,而在 来源

    Assistant
        │
        └── Action       来自模型(模型"想要"做什么)
    
    Tool
        │
        └── Observation  来自外部世界(真实发生了什么)
    

    Action 来自模型,Observation 来自真实环境,二者绝对不能由同一个角色生成。

    为什么这么较真?因为如果 Observation 也由模型自己写,模型就能假装工具已经执行成功,编造一个根本没发生的结果。

    举个例子,假设这是模型自己一口气写出来的:

    Action:
    Search("Apple CEO")
    
    Observation:
    Tim Cook
    

    如果 Observation 也是模型生成的,那它完全可以瞎编 —— 哪怕搜索压根没执行,它也能”查到”一个名字,甚至编出一个错误答案。

    所以现代 Agent 一定会把工具的真实返回,作为一条独立 Message 插回上下文。这样模型才被迫面对真实结果,而不是自说自话。

    五、为什么 Thought 和 Action 又要分开?

    这是另一个容易绕晕的地方。

    既然 Thought 和 Action 在同一条 Assistant Message 里:

    Assistant Message
        Thought
        Action
    

    论文为什么还要把它们拆开讲?

    原因还是回到那两个概念:

    • Message 是通信协议 —— 描述”对外发出了什么”。
    • Thought / Action 是 Agent 的内部状态 —— 描述”脑子里在干什么”。

    它们说的是两件事。Thought 和 Action 分别对应决策的两个阶段:

    Thought:  我要知道天气          ← Decision(决定做什么)
       ↓
    Action:   weather("Shanghai")   ← 模型提出的执行指令
    

    用一句话区分:

    • Thought 是”我决定下一步做什么”;
    • Action 是”我真正发出的执行指令”。

    论文真正想表达的,是 LLM 如何一步步做出决策,而不是 API 长什么样。所以它在概念上把决策(Thought)和执行(Action)分开描述。

    一个常被忽略的细节:Action 其实跨了两个角色

    这里还有一层很多人没注意到的东西:Action 并不是一个单一动作,它内部又分成两半。

    • 第一半:LLM 提出动作。模型只是输出一段”我想调用 weather("Shanghai")“的意图,它本身并不会、也没能力真正去查天气。
    • 第二半:Agent 执行动作。Agent 运行时(也就是我们写的那段代码/框架)解析这段意图,真正去调用天气 API、跑数据库查询、执行 shell 命令。

    Observation,就是第二半”执行”之后拿回来的结果

    用角色把整条链路串起来会更清楚:

    LLM     │  Thought         我得查天气
            │  Action(intent)  我"想"调用 weather("Shanghai")   ← 只是提出
            ▼
    Agent   │  执行 Action      真正去调 weather API             ← 真正干活
            │  Observation     26℃,有雨                         ← 执行结果
            ▼
    LLM     │  Thought         有雨,不适合
    

    所以”Action → Observation”严格来说不是模型一个人完成的:模型负责提出,Agent 负责执行并取回结果。这也正好呼应第四节——Observation 必须独立,因为它来自 Agent 的真实执行,而不是模型的想象。

    Action 是逻辑概念,不等于 function calling

    还有一点要强调:Action 是论文里的逻辑概念,它并没有被”焊死”成 AI message 里的某个 function call 字段。

    论文中的 Action,本质是”Agent 决定并执行一次对外操作”这个抽象行为。它可以有很多种落地方式:

    • 早期是让模型按格式输出一行文本,比如 Search[Apple CEO],再由 Agent 用正则解析后执行;
    • 现在主流是 function calling / tool calling,模型直接吐出结构化的 tool_calls
    • 也可以是模型输出一段代码,由 Agent 丢进沙箱里跑(Code Act)。

    这些都是同一个 Action 概念的不同工程实现。function calling 只是目前最流行的那一种,而不是 Action 的定义本身。把”Action”和”function calling”画等号,恰恰是只看到了 Prompt/Message 层,没看到背后的 State 层。

    六、State 才是 ReAct 的真正核心

    理解了上面两节,就能看出:真正的 ReAct,本质是一个状态机

    Thought
       │
       ▼
    Action
       │
       ▼
    Observation
       │
       ▼
    Thought
       │
       ▼
    Action
       │
       ▼
    Observation
       │
       ▼
      ...
    

    如果写成代码,大致是这样一个循环:

    while not finished:
        thought = llm(history)            # LLM:决策 + 提出动作
        action = choose_tool(thought)     # 取出模型想调用的工具
        observation = run(action)         # Agent:真正执行,拿回结果
        history.append(observation)       # 拼回上下文,进入下一轮
    

    四个要素各司其职:

    • Thought:Agent 当前的决策;
    • Action:Agent 请求执行的动作;
    • Observation:环境给回来的反馈;
    • History:不断累积的上下文。

    整个循环反复进行,直到模型认为可以收尾,输出最终答案。

    七、现代 Function Calling 里,Thought 去哪了?

    如果你用过 OpenAI、Claude、Gemini 的工具调用,会发现它们其实不再输出这样的文本:

    Thought:
    ...
    
    Action:
    ...
    

    而是直接吐出结构化的工具调用:

    {
        "tool_calls": [
            {
                "function": "weather",
                "arguments": {
                    "city": "Shanghai"
                }
            }
        ]
    }
    

    程序执行工具后,把结果作为一条 tool 消息塞回去:

    {
        "role": "tool",
        "content": "26℃, humidity 90%, rain"
    }
    

    最后再调一次 LLM 得到最终答案:

    User
       ↓
    Assistant(tool_call)
       ↓
    Tool(result)
       ↓
    Assistant(final answer)
    

    整个过程里,已经看不到 Thought 了。

    但这不代表 Thought 消失了:

    Thought 没有消失,只是从”显式写在 Prompt 里”变成了”模型内部的隐式推理(Hidden Reasoning)”。

    现代模型通常不会把这段推理过程直接暴露给开发者(推理模型会把它放进单独的 reasoning 字段)。决策这一步依然存在,只是藏到了模型内部。

    八、ReAct Inside:站在 LLM 内部看全流程

    如果把视角切到 LLM 内部,整个流程可以画成这样:

                    +----------------+
                    | User Message   |
                    +--------+-------+
                             |
                             ▼
                  +-------------------+
                  | Internal Reasoning|
                  | (Thought)         |
                  +--------+----------+
                           |
                           ▼
                  +-------------------+
                  | Tool Selection    |
                  | (Action)          |
                  +--------+----------+
                           |
                           ▼
                  +-------------------+
                  | Tool Execution    |
                  +--------+----------+
                           |
                           ▼
                  +-------------------+
                  | Observation       |
                  | (Tool Message)    |
                  +--------+----------+
                           |
                           ▼
                  +-------------------+
                  | Internal Reasoning|
                  | (Thought)         |
                  +--------+----------+
                           |
                           ▼
                     Final Answer
    

    真正在循环的,是这三个动作:

    Reason → Act → Observe → Reason → ...
    

    而不是很多人以为的:

    Prompt → Prompt → Prompt → ...
    

    换句话说,循环的主体是状态的流转,而不是一段段文本格式的堆叠。

    九、用三个层次理解 ReAct

    把前面的内容收一下,可以从三个层次来看 ReAct。

    第一层是 Prompt。论文里的 Thought / Action / Observation,只是为了方便把推理轨迹展示出来,是给人看的”展示格式”。

    第二层是 Message。现代 Agent 真正交换的消息只有三类:User、Assistant、Tool。这是落到 API 上的”通信协议”。

    第三层是 State,也是真正的核心。它描述的是 Agent 内部的状态流转:

    Decision(决策)
       ↓
    Execution(执行)
       ↓
    Environment Feedback(环境反馈)
       ↓
    Decision(再决策)
    

    这套状态机,才是 ReAct 的本质。

    十、总结

    一句话总结 ReAct:

    ReAct 不是一种 Prompt 模板,而是一种 Agent 的状态机。

    理解它,关键是分清三个层次:

    • Prompt 层Thought / Action / Observation,只是用来表达推理过程的展示格式。
    • Message 层User / Assistant / Tool,是实际的 API 通信协议。
    • State 层Thought → Action → Observation,是 Agent 真正的内部状态机。

    现代 Function Calling 虽然不再显式输出 Thought,但底层依然遵循同样的状态转换:

    Reason → Act → Observe → Reason → ...
    

    所以可以这样理解二者的关系:

    Function Calling 是 ReAct 的工程实现;ReAct 是 Function Calling 的设计思想。

    如果觉得这篇文章对你有帮助,欢迎点赞、收藏加关注。后续持续分享更多有价值的内容。你的支持是我创作的最大动力!

  • 设计取舍:为什么 Hermes(以及很多流行 agent)不用 LangChain / LangGraph

    说明:Hermes 仓库里没有一句明确写下「我们不用 LangChain 因为……」的声明。
    本文分两层:行业层面的普遍原因(通用分析)与 Hermes 代码中可实证看到的取向(有源码依据,标注出处)。

    1. 前提:agent 循环的内核其实很简单

    很多人以为编排 agent 必须依赖一个「框架」,但核心循环本质上只是一个 while:

    while 未达终止条件:
        resp = llm(messages, tools)
        if resp.tool_calls:
            执行工具,把结果 append 回 messages
        else:
            return resp.content
    

    Hermes 的 run_conversationagent/conversation_loop.py)本质就是这个。

    真正难的不是这个循环本身,而是它周边的工程细节:流式输出、中断、预算控制、上下文压缩、prompt 缓存、provider 故障切换、并发工具执行、错误分类与重试……而这些恰恰是通用框架抽象得最浅、最容易「挡路」的地方。这是理解「为什么很多项目不用框架」的关键。

    2. 行业层面:流行 agent 项目常绕开框架的普遍原因

    适用于多数自研循环的项目(Hermes、OpenHands、Aider、Codex CLI、Claude Code 等):

    1. 抽象与控制权错配。框架把 LLM 调用 / 消息 / 记忆 / 工具封装成对象(Chain / Runnable / Graph 节点)。但生产级 agent 需要对「发给模型的每一个 byte」精确控制——例如 Anthropic 的 cache_control 打在哪条消息、reasoning content 如何存、报错时怎样降级到 fallback 模型。框架抽象让这「最后一公里」更难做,常被迫绕过框架 monkey-patch。打个比方:框架像一台万能遥控器,常见电器都能控,但你家那台带最新功能的机器它偏偏少了一个键,你只能拆开后盖直接接线。

    2. 多 provider 的 API 形态差异。需要同时支持 OpenAI chat.completions、Anthropic Messages、Bedrock、Codex Responses 等不同 API 形态时,框架的「统一 LLM 接口」往往滞后于各家最新特性(新模型、新参数、reasoning、prompt caching),追新被动。就像翻译软件总比原文晚一步:各家模型刚发布的新能力,框架要等下一个版本才支持,而你想第一时间用上。

    3. 调试与可读性。自研循环的 stack trace 直达自身代码;框架常是多层抽象 + 回调,报错栈深、行为隐式。长期维护项目更看重可读性。

    4. 依赖与供应链风险。框架本身是庞大的传递依赖树,版本变动频繁、API 不稳定,放大供应链攻击面。

    5. 版本 churn。LangChain 早期 API 变动剧烈(LLMChain → LCEL → LangGraph),把核心逻辑绑在快速变动的框架上,迁移成本高。

    3. Hermes 代码中可实证看到的取向(有据可查)

    • 极致依赖最小化 + 供应链防御pyproject.toml 有大段注释说明:核心依赖全部精确钉死版本==X.Y.Z,不用范围),起因是 2026-05 的 Mini Shai-Hulud 蠕虫攻击;并明确写道 “smaller dependencies = smaller blast radius for the next supply-chain attack”,provider 专属依赖一律惰性安装(tools/lazy_deps.py)。一个把「依赖面积」当一等公民管理的项目,自然不会引入 LangChain 这种重依赖。

    • 唯一的 LLM SDK 是 openai==2.24.0,其余多 provider 全靠自研 Transport / Adapter 层(agent/transports/agent/*_adapter.py)适配,统一以 OpenAI 消息格式为中间表示。

    • 循环周边大量自研工程:中断检查、agent/iteration_budget.pyagent/error_classifier.py(故障切换)、agent/context_compressor.pyagent/prompt_caching.pyagent/tool_executor.py(并发工具执行)。仅 run_agent.py 单文件就有约 5300 行,循环相关模块合计上万行,说明他们刻意投入去拥有这个循环,而非外包给框架。

    • 并非「什么都自己造」。当愿意把控制权交出去时(如把工具循环交给 OpenAI Codex app-server),Hermes 会显式集成;它反对的是「用通用框架替代自己的核心循环」,而非反对一切集成。

    概括 Hermes 的「理由」:把可控性与供应链安全当第一优先级,而 agent 核心循环又足够简单,自研的收益 > 框架带来的便利。

    4. 深入:极致依赖最小化 + 供应链防御

    Hermes 的供应链防御不是口号,而是落在 pyproject.toml + 四个具体模块里的多层机制。理解这套纪律,就能明白「不用框架」为何是它的必然推论而非口味选择。

    触发这套设计的真实攻击

    hermes_cli/security_advisories.pypyproject.toml 注释都点名了同一起事件:

    Mini Shai-Hulud worm(2026-05) —— 在 PyPI 上投毒了 mistralai 2.4.6。这是一类「自我传播的供应链蠕虫」:攻陷某维护者账号 → 发布带恶意代码的新版本 → 恶意代码在安装/运行时窃取更多凭证 → 用偷来的凭证投毒更多包,滚雪球扩散。

    pyproject.toml 写得很直白:若当时 mistralai 用 >=2.3.0,<3 这种范围声明,则在该恶意版本被隔离前的几小时内,每一次 install 都会自动拉到投毒版本。这就是钉死版本的直接动机。

    攻击类型 → 防御策略对照

    把 Hermes 的防御逐条拆开,每条都对应一类具体攻击场景:

    1. PyPI 投毒新版本(蠕虫或被劫持账号发布恶意 X.Y.Z+1)。对应策略:核心依赖全部精确钉死 ==X.Y.Z,配 uv.lock 锁定传递依赖;新版本只能通过「人为改 pin + 重新 lock + code review」进入。证据:pyproject.toml 注释 +[project.dependencies] 全是 ==

    2. 传递依赖爆炸面(直接依赖虽少,但间接拉进上百个包,任一被投毒都中招)。对应策略:核心依赖最小化,只有「每个 session 都用到」的包进 core;provider/搜索/TTS/消息平台等专属依赖踢出核心,改为惰性安装。证据:pyproject.toml 的「Scope rule」注释 + tools/lazy_deps.py 里的 LAZY_DEPS

    3. [all] 连坐失败(某 extra 的传递依赖被隔离,导致整个 [all] 解析失败,新装用户静默退化丢功能)。对应策略:把可选 backend 从 [all] 移到 lazy-install,单包隔离只影响该功能,不连累其它。证据:tools/lazy_deps.py docstring 的「Fragility」段 + [all] 注释。

    4. 恶意 MCP 扩展包npx/uvx 拉的第三方 MCP server 可能是投毒包)。对应策略:启动前查 OSV 数据库,命中 MAL-* 恶意软件公告就 BLOCK——只拦确认的恶意软件、不拦普通 CVE,且网络失败时放行(fail-open)。证据:tools/osv_check.py::check_package_for_malware

    5. 配置劫持安装源(恶意 config 把安装重定向到攻击者镜像、git 或本地路径)。对应策略:lazy-install 只允许从 PyPI 按包名装,不支持 --index-url/git+https/file:,只能装白名单 spec,且仅作用于当前 venv,绝不碰系统 Python。证据:tools/lazy_deps.py 的「Security model」段。

    6. 已知 CVE 的依赖。对应策略:在钉死版本上逐条标注 CVE(requests/aiohttp/starlette/PyJWT/anthropic 等),升级有意为之。证据:pyproject.toml 内联的 # CVE-2026-xxxxx 注释。

    7. 投毒包已装进用户环境(防线突破后的检测兜底)。对应策略:每次 CLI/gateway 启动用 importlib.metadata.version() 比对已知被攻陷版本清单,命中即告警 + 给修复指引;用户可 hermes doctor --ack <id> 确认并持久化。证据:hermes_cli/security_advisories.pyADVISORIES

    关键策略再展开

    • 钉死版本 + lockfile(策略 1):范围声明把「何时拉到新版本」的决定权交给 PyPI 和时间;钉死则收回到「一次显式人工提交」。代价是手动 uv lock,收益是攻击者没有自动到达用户的通道。pyproject 明确要求:升级必须同时改 pin 并重新生成 uv.lock,「不要在没有书面理由时把范围加回来」。

    • 最小化 + 惰性安装(策略 2、3)—— “blast radius” 的核心:原文 “smaller dependencies = smaller blast radius for the next supply-chain attack” 的工程含义是:核心依赖列表越短,下次供应链攻击波及你的概率越低。所以 anthropicfirecrawledge-ttsmodalmautrixelevenlabs 等几十个 provider 专属包全部移出 core,改由 lazy_deps.ensure("feature.name") 在首次用到时现装。只用一家模型的用户,永远不会把其它几十家 provider 的依赖树拉进攻击面。

    • OSV 恶意软件拦截(策略 4):唯一一处「主动外呼查询」——在 agent 真正 npx/uvx 启动 MCP server 之前,先问 Google OSV API「这个包有没有 MAL-* 公告」。它故意只拦确认恶意软件、不拦普通 CVE(避免误杀),且 fail-open(网络失败放行,不阻断正常使用)。灵感来自 Block/goose 的扩展检查。

    为什么这套纪律天然排斥 LangChain

    把上面串起来:LangChain/LangGraph 是重依赖,自身又拖一棵庞大且高频变动的传递依赖树。对一个把「core 依赖必须短、每个包都要能 CVE 标注、可选依赖一律惰性化」当硬规矩的项目,引入这种框架等于一次性破坏策略 1/2/3/6——blast radius 直接爆炸。所以「不用框架」不是孤立的口味,而是这套供应链纪律的必然推论

    补充:依赖只是其中一层

    SECURITY.md 的 trust model 显示供应链只是防御的一部分。Hermes 把所有「进入 agent 上下文的内容」(web 抓取、邮件、gateway 消息、文件、MCP 响应、工具结果)都列为不可信输入面;另有 tools/url_safety.pytools/threat_patterns.pytools/skills_guard.pytools/skills_ast_audit.pytools/tirith_security.py 处理 prompt 注入与技能代码审计。依赖最小化解决「你装进来的代码可信吗」,这些模块解决「跑起来后喂给模型的数据可信吗」。

    5. 不用框架的优缺点

    优点

    • 完全控制 prompt / 消息 / 缓存 / 重试 / 降级,能第一时间用上各家模型新特性。
    • 依赖少、攻击面小、构建可复现、长期可维护。
    • 调试直观,栈短,行为显式。
    • 不受框架版本升级牵连。

    缺点

    • 要自己造很多轮子:重试、压缩、记忆、工具 schema、并发、可观测性——Hermes 为此写了上万行,成本真实存在。
    • 缺少生态即插即用:LangChain 有海量现成 retriever / loader / 集成,自研要逐个接。
    • 概念需自己沉淀:图编排、状态机、checkpoint 等 LangGraph 直接提供的能力要自行设计(Hermes 用 Kanban + delegate 自实现了类似能力)。
    • 团队上手曲线:没有通用框架的共同词汇,新人要读项目私有抽象。

    6. 什么时候反而该用框架

    平衡地看,框架并非没价值:
    快速原型 / Demo / 一次性脚本:现成集成省时间。
    需要复杂、可视化的有状态编排且不想自研:LangGraph 的图 / checkpoint / human-in-the-loop 是真实价值。
    团队不愿维护底层循环,愿用抽象约束换取速度。

    经验法则:探索期用框架跑得快;一旦产品要长期演进、要精细控制模型行为、要控依赖与安全,多数严肃项目会像 Hermes 一样收敛回「自研瘦核心循环 + OpenAI SDK」。这也是 Aider、Codex CLI、Claude Code 等流行 coding agent 同样不依赖 LangChain / LangGraph 的原因。

    7. 一句话总结

    agent 的核心循环简单到不值得用重框架去封装,而循环周边真正难的工程(缓存 / 降级 / 压缩 / 多 provider / 供应链)又恰恰是框架抽象会挡路的地方——所以 Hermes 选择自研瘦核心循环,用依赖最小化换取可控性与安全性。

    如果觉得这篇文章对你有帮助,欢迎点赞、收藏加关注。后续持续分享更多有价值的内容。你的支持是我创作的最大动力!