基于模式提示的复杂多智能体系统构建——在 RabbitMQ 上实现 ReAct 模式

0 阅读26分钟

上一章介绍了 Topologos,并演示了如何设计一个 RabbitMQ 拓扑。在本章中,你将在这个基础之上,实现一个智能体模式。

这里使用的模式是 ReAct,也就是 Reasoning and Acting,遵循一个 Thought → Action → Observation 的循环。agent 会调用 LLM 来决定使用哪个工具,把工具调用作为消息分发出去,接收结果作为 observation,然后重复这个过程,直到生成最终答案。

在本章中,你将构建一个完整、可运行的 Python 系统来实现这个模式。它包括一个独立 agent、三个工具 worker、一个用于发送问题的 publisher,以及连接它们的 RabbitMQ 拓扑。

具体来说,你将使用以下组件:

  • react_agent.py:实现核心的 Thought / Action / Observation 循环,并与 RabbitMQ 集成。
  • tool_worker.py:为 search、calculator 和 weather 工具提供可以独立扩展的 worker。
  • publish_command.py:用于向 agent 发送问题的 CLI。
  • topology.json:定义完整的 RabbitMQ 设置,包括三层死信队列和 quarantine 队列。

在这个过程中,你将探索一些关键概念,例如贯穿推理链的 correlation ID、手动确认、三层死信队列处理、Competing Consumers 模式,以及将故障分类为 poison、transient 或 permanent。

在本章中,我们将讨论以下主题:

  • 理解 ReAct 模式
  • 设置 RabbitMQ 拓扑
  • 构建 ReAct agent
  • 实现工具 worker
  • 向 agent 发送命令
  • 端到端运行系统
  • 观察死信队列
  • 为生产环境准备系统

本章中的所有示例都可以运行,并且提供完整源代码。到本章结束时,你将拥有一个可工作的 ReAct agent,它能够围绕问题进行推理,通过 RabbitMQ 分发工具调用,接收 observation,并生成最终答案。

如果想跟着本章操作,你需要 RabbitMQ 3.12 或更高版本、Python 3.11 或更高版本,以及一个 Anthropic API key。

理解 ReAct 模式

ReAct 是由 Yao 等人在 2022 年的一篇论文中提出的。这个名字结合了 Reasoning 和 Acting。agent 会在两类操作之间交替:

Thought: agent 对问题当前状态进行推理,并决定下一步该做什么。

Action: agent 使用特定输入调用一个工具,并接收一个 Observation。

这个循环会不断重复,直到 agent 拥有足够信息,可以生成 Final Answer。循环如下所示:

image.png

图 9.1 —— ReAct 循环,展示 Thought → Action → Observation 周期

在本实现中,agent 会使用 basic_get 轮询 react.tool.results.queue,并通过 correlation_id 进行过滤。在高并发系统中,这种做法可以替换为由 agent 声明、并在完成后删除的按 correlation 独立 reply queue。

设置 RabbitMQ 拓扑

要在分布式系统中运行 ReAct 循环,你需要一个消息层,用来路由命令、分发工具调用,并处理故障。在本节中,你将设置支撑这一流程的 RabbitMQ 拓扑。

使用 management HTTP API 将 topology.json 加载到 RabbitMQ:

curl -u guest:guest -X POST \
  http://localhost:15672/api/definitions \
  -H 'Content-Type: application/json' \
  -d @topology.json

该拓扑定义了一组 exchange 和 queue,用于处理命令、工具分发、结果和故障路由:

元素名称类型 / 配置
vhost/react将示例与 broker 上其他工作负载隔离
exchangereact.commands.exchangetopic —— 接收来自 publisher 的问题
exchangereact.tool.dispatch.exchangetopic —— agent 在这里发布工具请求
exchangereact.tool.results.exchangetopic —— 工具 worker 在这里发布结果
exchangereact.dlq.retry1.exchangetopic —— 第一层重试,30 秒 TTL 队列
exchangereact.dlq.retry2.exchangetopic —— 第二层重试,5 分钟 TTL 队列
exchangereact.dlq.quarantine.exchangefanout —— 终止型 DLQ,只允许手动重放
queuereact.commands.queuedurable,DLX → retry1
queuereact.tool.search.queuedurable,DLX → retry1
queuereact.tool.calculator.queuedurable,DLX → retry1
queuereact.tool.weather.queuedurable,DLX → retry1
queuereact.tool.results.queuedurable,DLX → retry1,TTL 5 分钟
queuereact.quarantine.queuedurable,无 TTL,fanout 绑定

表 9.1 —— ReAct 系统中使用的 RabbitMQ 拓扑元素

完整拓扑定义在 topology.json 中,其中声明了 vhost、用户、exchange、queue 以及它们的 binding:

{
  "rabbit_version": "3.12.0",
  "vhosts": [
    { "name": "/react" }
  ],
  "users": [
    {
      "name":     "react.agent",
      "password": "CHANGE_ME_agent",
      "tags":     ""
    },
    {
      "name":     "react.operator",
      "password": "CHANGE_ME_operator",
      "tags":     "management"
    }
  ],
  "permissions": [
    {
      "user":      "react.agent",
      "vhost":     "/react",
      "configure": "react\..*",
      "write":     "react\..*",
      "read":      "react\..*"
    },
    {
      "user":      "react.operator",
      "vhost":     "/react",
      "configure": "react\..*",
      "write":     "react\..*",
      "read":      "react\..*"
    }
  ],
  "exchanges": [
    {
      "name":        "react.commands.exchange",
      "vhost":       "/react",
      "type":        "topic",
      "durable":     true,
      "auto_delete": false,
      "arguments":   {
        "alternate-exchange": "react.dlq.quarantine.exchange"
      }
    },
    {
      "name":        "react.tool.dispatch.exchange",
      "vhost":       "/react",
      "type":        "topic",
      "durable":     true,
      "auto_delete": false,
      "arguments":   {
        "alternate-exchange": "react.dlq.quarantine.exchange"
      }
    },
    {
      "name":        "react.tool.results.exchange",
      "vhost":       "/react",
      "type":        "topic",
      "durable":     true,
      "auto_delete": false
    },
    {
      "name":        "react.dlq.retry1.exchange",
      "vhost":       "/react",
      "type":        "topic",
      "durable":     true,
      "auto_delete": false
    },
    {
      "name":        "react.dlq.retry2.exchange",
      "vhost":       "/react",
      "type":        "topic",
      "durable":     true,
      "auto_delete": false
    },
    {
      "name":        "react.dlq.quarantine.exchange",
      "vhost":       "/react",
      "type":        "fanout",
      "durable":     true,
      "auto_delete": false
    }
  ],
  "queues": [
    {
      "name":        "react.commands.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry1.exchange",
        "x-dead-letter-routing-key":  "dlq.react.commands.queue"
      }
    },
    {
      "name":        "react.tool.search.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry1.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.search.queue"
      }
    },
    {
      "name":        "react.tool.calculator.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry1.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.calculator.queue"
      }
    },
    {
      "name":        "react.tool.weather.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry1.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.weather.queue"
      }
    },
    {
      "name":        "react.tool.results.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry1.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.results.queue",
        "x-message-ttl":              300000
      }
    },
    {
      "name":        "react.commands.retry1.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry2.exchange",
        "x-dead-letter-routing-key":  "dlq.react.commands.queue",
        "x-message-ttl":              30000
      }
    },
    {
      "name":        "react.commands.retry2.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.quarantine.exchange",
        "x-message-ttl":              300000
      }
    },
    {
      "name":        "react.tool.search.retry1.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry2.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.search.queue",
        "x-message-ttl":              30000
      }
    },
    {
      "name":        "react.tool.search.retry2.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.quarantine.exchange",
        "x-message-ttl":              300000
      }
    },
    {
      "name":        "react.tool.calculator.retry1.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry2.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.calculator.queue",
        "x-message-ttl":              30000
      }
    },
    {
      "name":        "react.tool.calculator.retry2.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.quarantine.exchange",
        "x-message-ttl":              300000
      }
    },
    {
      "name":        "react.tool.weather.retry1.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.retry2.exchange",
        "x-dead-letter-routing-key":  "dlq.react.tool.weather.queue",
        "x-message-ttl":              30000
      }
    },
    {
      "name":        "react.tool.weather.retry2.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments": {
        "x-dead-letter-exchange":     "react.dlq.quarantine.exchange",
        "x-message-ttl":              300000
      }
    },
    {
      "name":        "react.quarantine.queue",
      "vhost":       "/react",
      "durable":     true,
      "auto_delete": false,
      "arguments":   {}
    }
  ],
  "bindings": [
    {
      "source":           "react.commands.exchange",
      "vhost":            "/react",
      "destination":      "react.commands.queue",
      "destination_type": "queue",
      "routing_key":      "react.command.#",
      "arguments":        {}
    },
    {
      "source":           "react.tool.dispatch.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.search.queue",
      "destination_type": "queue",
      "routing_key":      "react.tool.search.#",
      "arguments":        {}
    },
    {
      "source":           "react.tool.dispatch.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.calculator.queue",
      "destination_type": "queue",
      "routing_key":      "react.tool.calculator.#",
      "arguments":        {}
    },
    {
      "source":           "react.tool.dispatch.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.weather.queue",
      "destination_type": "queue",
      "routing_key":      "react.tool.weather.#",
      "arguments":        {}
    },
    {
      "source":           "react.tool.results.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.results.queue",
      "destination_type": "queue",
      "routing_key":      "react.result.#",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry1.exchange",
      "vhost":            "/react",
      "destination":      "react.commands.retry1.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.commands.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry1.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.search.retry1.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.tool.search.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry1.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.calculator.retry1.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.tool.calculator.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry1.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.weather.retry1.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.tool.weather.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry2.exchange",
      "vhost":            "/react",
      "destination":      "react.commands.retry2.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.commands.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry2.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.search.retry2.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.tool.search.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry2.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.calculator.retry2.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.tool.calculator.queue",
      "arguments":        {}
    },
    {
      "source":           "react.dlq.retry2.exchange",
      "vhost":            "/react",
      "destination":      "react.tool.weather.retry2.queue",
      "destination_type": "queue",
      "routing_key":      "dlq.react.tool.weather.queue",
      "arguments":        {}
    }
  ]
}

这个配置为系统设置了完整的消息基础设施。它定义了命令如何被接收、工具请求如何被路由到 worker,以及结果和故障如何通过重试队列与 quarantine 队列进行处理。dead-letter exchange 确保失败消息会经过一个受控的重试流程,而不是丢失。

构建 ReAct agent

消息基础设施准备好之后,下一步就是实现驱动 Thought → Action → Observation 循环的 agent。

agent 有四项职责:接收命令,调用 LLM 生成 thought 和 action,分发工具调用,把结果作为 observation 消费,然后重复这个过程,直到生成最终答案。这些职责会直接映射到代码中的组件。

消息 schema

三个 dataclass 表示在线路上传输的消息。Command 携带传入的问题和累计历史。ToolRequest 被发布到 dispatch exchange。ToolResult 从 results queue 中被消费。

三者中最关键的属性都是 correlation_id。它在命令首次被接收时设置一次,并在同一条推理链中的每个 ToolRequestToolResult 中保持不变。当 agent 轮询 results queue 时,它会丢弃任何 correlation_id 不匹配的消息,因为这些消息属于另一个正在处理的问题,并会被重新入队。

LLM 调用:_think

每次调用 _think 时,都会重新构建完整的消息历史,并发送给 LLM。system prompt 会指示模型以两种形式之一返回有效 JSON:一种是包含工具名称和输入的 action step;另一种是 final answer。模型会被明确指示不要猜测工具结果。

如果模型返回非 JSON 输出,_think 会抛出 ValueError。这会被视为 poison-message failure,该 command 会被 NACK 且不重新入队,并通过 dead-letter exchange 直接路由到 quarantine。

工具分发:_dispatch_tool

_dispatch_tool 会把 ToolRequest 发布到 react.tool.dispatch.exchange,routing key 为 react.tool.<type>.dispatch。每个工具 worker queue 都绑定到这个模式,从而确保消息被交付到正确的 worker 类型。

发布之后,agent 会在循环中使用 basic_get 轮询 react.tool.results.queue,并比较 correlation_id。不匹配的消息会以 requeue=True 的方式被 NACK,这样它们仍然可被其他 agent 实例使用。如果在 30 秒内没有匹配结果到达,就会抛出 TimeoutError,command 会被 NACK 到 Tier 1 retry queue。

循环与 ACK 策略

on_command 回调会用 try / except 包装 run_loop,对故障进行分类,并决定每条消息如何被确认和路由:

异常故障类型ACK 行为DLQ 目标
json.JSONDecodeErrorValueErrorKeyErrorPoisonbasic_nack(requeue=False)→ retry1 → retry2 → quarantine
TimeoutErrorTransientbasic_nack(requeue=False)→ retry1,30 秒 → retry2,5 分钟 → quarantine
RuntimeError,步骤数限制Permanentbasic_nack(requeue=False)→ retry1 → retry2 → quarantine
未预期异常Unknownbasic_nack(requeue=False)→ retry1 → retry2 → quarantine

表 9.2 —— ReAct agent 中的故障分类和消息处理策略

在所有情况下,消息都会以 requeue=False 的方式被 NACK。queue 上的 dead-letter exchange 配置决定它们如何经过 retry chain,因此 agent 代码不需要显式处理路由。

agent 的完整实现如下:

"""
react_agent.py —— 基于 RabbitMQ 的 ReAct(Reasoning + Acting)agent。

agent 运行一个 Thought → Action → Observation 循环。每次迭代:
  1. 从 react.commands.queue 接收一条 Command 消息。
  2. 调用 LLM 生成 Thought 和 Action(工具名 + 输入)。
  3. 将 ToolRequest 发布到 react.tool.dispatch.exchange。
  4. 从 react.tool.results.queue 消费 ToolResult。
  5. 将 Observation 反馈回 LLM 上下文。
  6. 重复,直到 LLM 生成 Final Answer。

每条消息都携带:
  - correlation_id   从原始 Command 贯穿传递
  - x-step           当前循环迭代编号
  - x-failure-type   在 nack 时设置,用于引导 DLQ 路由(transient / permanent / poison)

依赖:
    pip install pika anthropic python-dotenv
"""

from __future__ import annotations

import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any

import anthropic
import pika
import pika.exceptions
from dotenv import load_dotenv

load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
log = logging.getLogger("react.agent")

# ── 常量 ─────────────────────────────────────────────────────────────────

RABBITMQ_URL        = os.getenv("RABBITMQ_URL", "amqp://react.agent:CHANGE_ME_agent@localhost:5672/%2Freact")
COMMAND_QUEUE       = "react.commands.queue"
TOOL_DISPATCH_EX    = "react.tool.dispatch.exchange"
TOOL_RESULTS_QUEUE  = "react.tool.results.queue"
MAX_STEPS           = int(os.getenv("REACT_MAX_STEPS", "10"))
PREFETCH_COUNT      = 1  # 每个 consumer 实例一次只处理一个 command

SYSTEM_PROMPT = """You are a ReAct agent. For each user question you must reason step by step.

On each step you MUST respond with valid JSON in exactly one of these two forms:

  Action step:
  {
    "thought": "<your reasoning>",
    "action": {
      "tool": "<search | calculator | weather>",
      "input": "<tool input string>"
    }
  }

  Final answer (only when you have enough information):
  {
    "thought": "<your final reasoning>",
    "final_answer": "<your complete answer to the user>"
  }

Available tools:
  search      — web search, returns a short summary
  calculator  — evaluates a mathematical expression, returns a number
  weather     — returns current weather for a city name

Rules:
  - Never guess tool results. Always call a tool if you need external information.
  - Use the minimum number of steps necessary.
  - Your JSON must be parseable. No trailing commas, no comments.
"""


# ── 消息 schema ───────────────────────────────────────────────────────────

@dataclass
class Command:
    """来自 react.commands.queue 的入站消息。"""
    correlation_id: str
    question: str
    step: int = 0
    history: list[dict[str, str]] = field(default_factory=list)

    @staticmethod
    def from_delivery(body: bytes, properties: pika.BasicProperties) -> "Command":
        data = json.loads(body)
        return Command(
            correlation_id=properties.correlation_id or str(uuid.uuid4()),
            question=data["question"],
            step=int((properties.headers or {}).get("x-step", 0)),
            history=data.get("history", []),
        )

    def to_amqp_properties(self) -> pika.BasicProperties:
        return pika.BasicProperties(
            correlation_id=self.correlation_id,
            content_type="application/json",
            delivery_mode=2,           # persistent
            headers={"x-step": self.step},
        )


@dataclass
class ToolRequest:
    """发布到 react.tool.dispatch.exchange。"""
    correlation_id: str
    tool: str
    input: str
    step: int
    reply_to: str = TOOL_RESULTS_QUEUE

    def routing_key(self) -> str:
        return f"react.tool.{self.tool}.dispatch"

    def to_body(self) -> bytes:
        return json.dumps({
            "tool":  self.tool,
            "input": self.input,
            "step":  self.step,
        }).encode()

    def to_amqp_properties(self) -> pika.BasicProperties:
        return pika.BasicProperties(
            correlation_id=self.correlation_id,
            reply_to=self.reply_to,
            content_type="application/json",
            delivery_mode=2,
            headers={"x-step": self.step},
        )


@dataclass
class ToolResult:
    """从 react.tool.results.queue 消费。"""
    correlation_id: str
    tool: str
    output: str
    step: int
    error: str | None = None

    @staticmethod
    def from_delivery(body: bytes, properties: pika.BasicProperties) -> "ToolResult":
        data = json.loads(body)
        return ToolResult(
            correlation_id=properties.correlation_id or "",
            tool=data["tool"],
            output=data.get("output", ""),
            step=int((properties.headers or {}).get("x-step", 0)),
            error=data.get("error"),
        )


# ── ReAct 循环 ────────────────────────────────────────────────────────────────

class ReactAgent:
    """
    按消息无状态运行的 ReAct 循环。

    connection 和 channel 在启动时创建一次,并跨消息复用。
    如果 broker 断开连接,main() 中的外层重试循环会重新连接。
    """

    def __init__(self, channel: pika.adapters.blocking_connection.BlockingChannel) -> None:
        self.channel = channel
        self.llm = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

    # ── LLM 调用 ──────────────────────────────────────────────────────────────

    def _think(self, question: str, history: list[dict[str, str]]) -> dict[str, Any]:
        """
        使用当前 question + observation history 调用 LLM。
        返回解析后的 JSON dict —— 要么是 action step,要么是 final answer。
        如果输出不可解析,则抛出 ValueError(poison-message 路径)。
        """
        messages: list[dict[str, str]] = [
            {"role": "user", "content": question},
        ]
        for entry in history:
            messages.append({"role": "assistant", "content": entry["assistant"]})
            if "observation" in entry:
                messages.append({"role": "user",      "content": f"Observation: {entry['observation']}"})

        response = self.llm.messages.create(
            model="claude-opus-4-5",
            max_tokens=512,
            system=SYSTEM_PROMPT,
            messages=messages,
        )

        raw = response.content[0].text.strip()
        log.debug("LLM raw output: %s", raw)

        try:
            return json.loads(raw)
        except json.JSONDecodeError as exc:
            raise ValueError(f"LLM returned non-JSON: {raw!r}") from exc

    # ── 工具分发 ─────────────────────────────────────────────────────────

    def _dispatch_tool(self, request: ToolRequest) -> ToolResult:
        """
        发布 ToolRequest,并阻塞等待匹配的 ToolResult 到达。
        使用 correlation_id 过滤结果;丢弃不相关消息。
        如果 30 秒内没有结果到达,则抛出 TimeoutError。
        """
        self.channel.basic_publish(
            exchange=TOOL_DISPATCH_EX,
            routing_key=request.routing_key(),
            body=request.to_body(),
            properties=request.to_amqp_properties(),
        )
        log.info("[%s] step=%d  → tool=%s  input=%r",
                 request.correlation_id, request.step, request.tool, request.input)

        # 轮询 results queue,直到看到自己的 correlation_id。
        # 在生产中,应改用按 correlation 独立 reply queue。
        deadline = 30  # seconds
        elapsed = 0
        poll_interval = 0.2

        while elapsed < deadline:
            method, properties, body = self.channel.basic_get(
                queue=TOOL_RESULTS_QUEUE, auto_ack=False
            )
            if method is None:
                import time
                time.sleep(poll_interval)
                elapsed += poll_interval
                continue

            result = ToolResult.from_delivery(body, properties)

            if result.correlation_id != request.correlation_id:
                # 不是自己的消息 —— 放回去(nack + requeue)。
                self.channel.basic_nack(method.delivery_tag, requeue=True)
                import time
                time.sleep(poll_interval)
                elapsed += poll_interval
                continue

            self.channel.basic_ack(method.delivery_tag)
            return result

        raise TimeoutError(
            f"No tool result for correlation_id={request.correlation_id} "
            f"tool={request.tool} after {deadline}s"
        )

    # ── 主循环 ─────────────────────────────────────────────────────────────

    def run_loop(self, cmd: Command) -> str:
        """
        为一条 Command 执行 ReAct 循环。
        返回 final answer 字符串。
        对不可恢复故障抛出异常(由调用者处理 ack/nack)。
        """
        history: list[dict[str, str]] = list(cmd.history)
        step = cmd.step

        for _ in range(MAX_STEPS - step):
            step += 1
            log.info("[%s] step=%d  THINK", cmd.correlation_id, step)

            decision = self._think(cmd.question, history)

            if "final_answer" in decision:
                log.info("[%s] step=%d  FINAL ANSWER", cmd.correlation_id, step)
                return decision["final_answer"]

            if "action" not in decision:
                raise ValueError(f"LLM response missing 'action' and 'final_answer': {decision}")

            action    = decision["action"]
            tool_name = action.get("tool", "").strip().lower()
            tool_input = action.get("input", "")

            if tool_name not in {"search", "calculator", "weather"}:
                raise ValueError(f"Unknown tool: {tool_name!r}")

            request = ToolRequest(
                correlation_id=cmd.correlation_id,
                tool=tool_name,
                input=tool_input,
                step=step,
            )

            result = self._dispatch_tool(request)

            if result.error:
                observation = f"ERROR: {result.error}"
                log.warning("[%s] step=%d  tool=%s  error=%s",
                            cmd.correlation_id, step, tool_name, result.error)
            else:
                observation = result.output
                log.info("[%s] step=%d  ← tool=%s  output=%r",
                         cmd.correlation_id, step, tool_name, result.output[:120])

            history.append({
                "assistant":   json.dumps(decision),
                "observation": observation,
            })

        raise RuntimeError(
            f"Exceeded MAX_STEPS={MAX_STEPS} without final answer "
            f"for correlation_id={cmd.correlation_id}"
        )

    # ── AMQP consumer 回调 ────────────────────────────────────────────────

    def on_command(
        self,
        channel: pika.adapters.blocking_connection.BlockingChannel,
        method: pika.spec.Basic.Deliver,
        properties: pika.BasicProperties,
        body: bytes,
    ) -> None:
        """
        pika 针对 react.commands.queue 投递的每条消息都会调用此方法。
        负责处理 ack/nack 和 DLQ header 标记。
        """
        correlation_id = (properties.correlation_id or "unknown")
        log.info("[%s] received command", correlation_id)

        try:
            cmd    = Command.from_delivery(body, properties)
            answer = self.run_loop(cmd)
            log.info("[%s] DONE  answer=%r", correlation_id, answer[:200])
            channel.basic_ack(method.delivery_tag)

        except (json.JSONDecodeError, ValueError, KeyError) as exc:
            # Poison message —— 格式异常的 body 或不良 LLM 输出。
            # 跳过重试,直接进入 quarantine。
            log.error("[%s] POISON  %s", correlation_id, exc)
            channel.basic_nack(
                method.delivery_tag,
                requeue=False,
            )
            # 理想情况下,应通过每条消息策略设置 x-failure-type: permanent;
            # 这里作为示例只用日志标记。

        except TimeoutError as exc:
            # Transient —— tool worker 可能正在重启。
            log.warning("[%s] TRANSIENT  %s", correlation_id, exc)
            channel.basic_nack(method.delivery_tag, requeue=False)
            # 通过 x-dead-letter-exchange 死信到 retry1(30 秒)。

        except RuntimeError as exc:
            # 超过步骤限制 —— 视为 permanent。
            log.error("[%s] PERMANENT  %s", correlation_id, exc)
            channel.basic_nack(method.delivery_tag, requeue=False)

        except Exception as exc:  # noqa: BLE001
            log.exception("[%s] UNEXPECTED  %s", correlation_id, exc)
            channel.basic_nack(method.delivery_tag, requeue=False)


# ── 入口点 ───────────────────────────────────────────────────────────────

def _make_channel() -> pika.adapters.blocking_connection.BlockingChannel:
    params  = pika.URLParameters(RABBITMQ_URL)
    conn    = pika.BlockingConnection(params)
    channel = conn.channel()
    channel.basic_qos(prefetch_count=PREFETCH_COUNT)
    return channel


def main() -> None:
    import time

    while True:
        try:
            log.info("Connecting to broker…")
            channel = _make_channel()
            agent   = ReactAgent(channel)

            channel.basic_consume(
                queue=COMMAND_QUEUE,
                on_message_callback=agent.on_command,
                auto_ack=False,
                consumer_tag=f"react.agent.{uuid.uuid4().hex[:8]}",
            )

            log.info("Waiting for commands on %s", COMMAND_QUEUE)
            channel.start_consuming()

        except pika.exceptions.AMQPConnectionError as exc:
            log.warning("Broker connection lost: %s — reconnecting in 5s", exc)
            time.sleep(5)

        except KeyboardInterrupt:
            log.info("Shutting down.")
            break


if __name__ == "__main__":
    main()

这段代码实现了完整的 ReAct 循环,包括消息处理、LLM 交互、工具分发和结果处理。它还处理故障分类,并使用手动确认和 correlation ID 与 RabbitMQ 集成。其结构对应了前面描述的职责,也就是接收命令、调用 LLM、分发工具请求,以及处理结果;循环中的每个部分都映射到一个具体函数。

实现工具 worker

有了 agent 之后,下一步是实现执行工具调用并把结果返回循环的 worker。工具 worker 是一个简单 consumer。它读取一个 ToolRequest,执行一个函数,然后发布一个 ToolResult。三个工具类型都实现在同一个文件中,并通过 CLI 参数选择:search、calculator 和 weather。

这些实现有意保持简单,使用基于关键词匹配的响应和安全算术求值器,因此示例无需外部 API 凭证也能运行。在生产环境中,每个函数都应该被替换成真实 API 调用。RabbitMQ wiring、acknowledgment 策略和错误处理保持不变。

工具 worker 中的错误处理

工具 worker 会区分两类错误:

ValueError: 输入是有效的,但工具无法处理它,例如未知城市或格式异常的表达式。在这种情况下,worker 会发布一个 error 字段已填充的 ToolResult,并确认该消息。agent 会把它作为 observation 接收,并对它进行推理,例如使用修正后的输入重试。

未预期异常: 很可能是瞬时的系统级故障。worker 会 NACK 该消息且不重新入队,将其路由到 Tier 1 retry queue。TTL 过期后,该消息会被重试。

这种区分是有意设计的。ValueError 表示 agent 可以处理的一种已知状况。未预期异常则被视为基础设施故障,由消息系统重试,而不是暴露给 LLM。

扩展

每种工具类型都作为独立 worker 进程运行。哪个类型成为瓶颈,就启动更多该类型的实例:

# 对低吞吐使用场景来说,一个 search worker 通常就足够
python tool_worker.py search &

# Calculator 是 CPU-bound 且很快;一个实例可以处理高负载
python tool_worker.py calculator &

# 如果外部 API 较慢,可以扩展 weather worker
python tool_worker.py weather &
python tool_worker.py weather &
python tool_worker.py weather &

这遵循 Competing Consumers 模式:同一 worker 类型的多个实例从同一个队列中消费,RabbitMQ 会在它们之间分发消息。不需要协调代码。

工具 worker 的完整实现如下:

"""
tool_worker.py —— 执行由 ReAct agent 分发的工具调用。

从以下队列之一消费:
    react.tool.search.queue
    react.tool.calculator.queue
    react.tool.weather.queue

将结果发布到 react.tool.results.exchange,routing key 为:
react.result.<tool>.<correlation_id_prefix>。

将工具名作为第一个 CLI 参数传入:
    python tool_worker.py search
    python tool_worker.py calculator
    python tool_worker.py weather

每种 worker 类型都可以独立扩展:哪个 worker 类型成为瓶颈,就运行更多该类型实例。

依赖:
    pip install pika python-dotenv
"""

from __future__ import annotations

import ast
import json
import logging
import math
import operator
import os
import sys
import uuid

import pika
import pika.exceptions
from dotenv import load_dotenv

load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")

RABBITMQ_URL       = os.getenv("RABBITMQ_URL", "amqp://react.agent:CHANGE_ME_agent@localhost:5672/%2Freact")
TOOL_RESULTS_EX    = "react.tool.results.exchange"
PREFETCH_COUNT     = 4   # tool worker 可以处理比主 agent 更高的并发


# ── 模拟工具实现 ───────────────────────────────────────────
# 在生产中,这些函数会调用真实 API。这里的 stub 有意保持简单,
# 使示例无需外部凭证也能运行。

def tool_search(query: str) -> str:
    """模拟 Web 搜索。根据关键词返回预置摘要。"""
    q = query.lower()
    if "eiffel tower" in q:
        return "The Eiffel Tower is 330 metres tall. It was completed in 1889 and is located in Paris, France."
    if "python" in q and "release" in q:
        return "Python 3.12 was released in October 2023. Python 3.13 is the current stable release as of 2025."
    if "rabbitmq" in q:
        return "RabbitMQ is an open-source message broker implementing AMQP. It supports topic, direct, fanout, and headers exchanges."
    return f"No results found for: {query}"


def tool_calculator(expression: str) -> str:
    """
    安全算术求值器。
    支持 +、-、*、/、**、sqrt 和括号。
    对不安全或格式异常的输入抛出 ValueError。
    """
    # 使用 ast 限制在安全子集内。
    allowed_nodes = (
        ast.Expression, ast.BinOp, ast.UnaryOp, ast.Num, ast.Constant,
        ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.USub, ast.UAdd,
        ast.Call, ast.Name, ast.Load,
    )
    allowed_names = {"sqrt": math.sqrt, "pi": math.pi, "e": math.e}

    try:
        tree = ast.parse(expression.strip(), mode="eval")
    except SyntaxError as exc:
        raise ValueError(f"Invalid expression: {expression!r}") from exc

    for node in ast.walk(tree):
        if not isinstance(node, allowed_nodes):
            raise ValueError(f"Unsafe expression node: {type(node).__name__}")
        if isinstance(node, ast.Name) and node.id not in allowed_names:
            raise ValueError(f"Unknown name: {node.id!r}")

    result = eval(  # noqa: S307  (安全 —— 已通过上面的 ast 检查限制)
        compile(tree, "<expr>", "eval"),
        {"__builtins__": {}},
        allowed_names,
    )
    return str(result)


def tool_weather(city: str) -> str:
    """模拟天气查询。"""
    city_l = city.strip().lower()
    data = {
        "london":        "Overcast, 12°C, wind 15 km/h SW",
        "new york":      "Partly cloudy, 18°C, wind 10 km/h NW",
        "tokyo":         "Sunny, 24°C, humidity 65%",
        "kuala lumpur":  "Thunderstorms likely, 31°C, humidity 88%",
        "paris":         "Clear, 16°C, wind 8 km/h N",
    }
    return data.get(city_l, f"No weather data available for: {city}")


TOOLS = {
    "search":     tool_search,
    "calculator": tool_calculator,
    "weather":    tool_weather,
}

TOOL_QUEUES = {
    "search":     "react.tool.search.queue",
    "calculator": "react.tool.calculator.queue",
    "weather":    "react.tool.weather.queue",
}


# ── Worker ────────────────────────────────────────────────────────────────────

class ToolWorker:
    def __init__(
        self, tool_name: str,
        channel: pika.adapters.blocking_connection.BlockingChannel
    ) -> None:
        if tool_name not in TOOLS:
            raise ValueError(f"Unknown tool: {tool_name!r}. Choose from: {list(TOOLS)}")
        self.tool_name = tool_name
        self.tool_fn   = TOOLS[tool_name]
        self.channel   = channel
        self.log       = logging.getLogger(f"tool.{tool_name}")

    def _publish_result(
        self,
        correlation_id: str,
        step: int,
        output: str | None,
        error: str | None,
    ) -> None:
        body = json.dumps({
            "tool":   self.tool_name,
            "output": output or "",
            "error":  error,
            "step":   step,
        }).encode()

        props = pika.BasicProperties(
            correlation_id=correlation_id,
            content_type="application/json",
            delivery_mode=2,
            headers={"x-step": step},
        )

        self.channel.basic_publish(
            exchange=TOOL_RESULTS_EX,
            routing_key=f"react.result.{self.tool_name}.{correlation_id[:8]}",
            body=body,
            properties=props,
        )

    def on_message(
        self,
        channel: pika.adapters.blocking_connection.BlockingChannel,
        method: pika.spec.Basic.Deliver,
        properties: pika.BasicProperties,
        body: bytes,
    ) -> None:
        correlation_id = properties.correlation_id or "unknown"
        step = int((properties.headers or {}).get("x-step", 0))

        try:
            data  = json.loads(body)
            inp   = data.get("input", "")
            self.log.info("[%s] step=%d  input=%r", correlation_id, step, inp[:120])

            output = self.tool_fn(inp)
            self._publish_result(
                correlation_id, step, output=output, error=None)
            self.log.info("[%s] step=%d  output=%r", correlation_id, step, output[:120])
            channel.basic_ack(method.delivery_tag)

        except (json.JSONDecodeError, KeyError) as exc:
            # Poison —— 格式异常的工具请求 body。
            self.log.error("[%s] POISON  %s", correlation_id, exc)
            channel.basic_nack(method.delivery_tag, requeue=False)

        except ValueError as exc:
            # Permanent tool error(错误表达式、未知城市等)。
            # 发布一个 error result,使 agent 能够对它进行推理,
            # 而不是让 agent 一直等到超时。
            self.log.warning("[%s] TOOL ERROR  %s", correlation_id, exc)
            self._publish_result(
                correlation_id, step, output=None, error=str(exc))
            channel.basic_ack(method.delivery_tag)

        except Exception as exc:  # noqa: BLE001
            # Transient —— 未知错误,允许重试。
            self.log.exception("[%s] UNEXPECTED  %s", correlation_id, exc)
            channel.basic_nack(method.delivery_tag, requeue=False)


def main(tool_name: str) -> None:
    import time

    while True:
        try:
            logging.info("Connecting to broker (tool=%s)…", tool_name)
            params  = pika.URLParameters(RABBITMQ_URL)
            conn    = pika.BlockingConnection(params)
            channel = conn.channel()
            channel.basic_qos(prefetch_count=PREFETCH_COUNT)

            worker = ToolWorker(tool_name, channel)
            queue  = TOOL_QUEUES[tool_name]

            channel.basic_consume(
                queue=queue,
                on_message_callback=worker.on_message,
                auto_ack=False,
                consumer_tag=f"tool.{tool_name}.{uuid.uuid4().hex[:8]}",
            )

            logging.info("Waiting for tool requests on %s", queue)
            channel.start_consuming()

        except pika.exceptions.AMQPConnectionError as exc:
            logging.warning("Broker connection lost: %s — reconnecting in 5s", exc)
            time.sleep(5)

        except KeyboardInterrupt:
            logging.info("Shutting down.")
            break


if __name__ == "__main__":
    if len(sys.argv) != 2 or sys.argv[1] not in TOOLS:
        print(f"Usage: python tool_worker.py <{'|'.join(TOOLS)}>",
            file=sys.stderr)
        sys.exit(1)
    main(sys.argv[1])

这段代码实现了一个通用工具 worker:它从队列中消费请求,执行对应工具函数,并把结果发布回 exchange。它还处理错误分类,区分工具级错误和系统故障,并使用手动确认与 RabbitMQ 集成。同一结构会在所有工具类型之间复用。

向 agent 发送命令

当 agent 和 worker 都运行起来后,下一步就是把一个问题发送到系统中,并观察它如何流经循环。publish_command.py 是一个很薄的 CLI wrapper,它会把一条 command message 发布到 react.commands.exchange,并生成一个新的 UUID 作为 correlation_id。它会打印出 correlation_id,这样你就可以在 agent 日志中 grep 这条特定推理链。

下面这个脚本会向 agent 发布一个问题,并打印 correlation ID 以便追踪:

"""
publish_command.py —— 向 ReAct agent 发布一个问题用于测试。

用法:
    python publish_command.py "What is the height of the Eiffel Tower in feet?"
    python publish_command.py "What is sqrt(144) + the current temperature in Tokyo?"

该脚本发布到 react.commands.exchange,并打印 correlation_id,
这样你可以通过 broker 追踪这条消息。

依赖:
    pip install pika python-dotenv
"""

from __future__ import annotations
import json
import sys
import uuid
import pika
from dotenv import load_dotenv
import os

load_dotenv()

RABBITMQ_URL     = os.getenv("RABBITMQ_URL", "amqp://react.agent:CHANGE_ME_agent@localhost:5672/%2Freact")
COMMANDS_EXCHANGE = "react.commands.exchange"
ROUTING_KEY       = "react.command.new"


def publish(question: str) -> str:
    correlation_id = str(uuid.uuid4())

    params  = pika.URLParameters(RABBITMQ_URL)
    conn    = pika.BlockingConnection(params)
    channel = conn.channel()

    body = json.dumps({"question": question}).encode()

    props = pika.BasicProperties(
        correlation_id=correlation_id,
        content_type="application/json",
        delivery_mode=2,
        headers={"x-step": 0},
    )

    channel.basic_publish(
        exchange=COMMANDS_EXCHANGE,
        routing_key=ROUTING_KEY,
        body=body,
        properties=props,
    )

    conn.close()
    return correlation_id


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python publish_command.py "<question>"", file=sys.stderr)
        sys.exit(1)

    question = " ".join(sys.argv[1:])
    cid = publish(question)
    print(f"Published question: {question!r}")
    print(f"correlation_id:     {cid}")
    print("Watch react_agent.py logs for the Thought → Action → Observation trace.")

这个脚本会创建到 RabbitMQ 的连接,构造一条包含问题的 command message,并使用固定 routing key 将其发布到 commands exchange。correlation_id 只生成一次,并附加到消息上,这样它就可以跨 agent 和工具 worker 被追踪。该消息被标记为 persistent,并在 header 中包含初始 step 值。

端到端运行系统

本节将演示如何设置环境、启动每个组件,并通过系统发送一条测试请求。开始之前,请确保你已经具备以下条件:

  • 本地运行或可通过 .env 中 URL 访问的 RabbitMQ 3.12 或更高版本
  • Python 3.11 或更高版本
  • 一个 Anthropic API key

设置

准备环境并加载消息拓扑:

# 将四个源文件 clone 或复制到一个目录中
cd react_example

# 安装依赖
pip install -r requirements.txt

# 配置凭证
cp .env.example .env
# 编辑 .env:设置 ANTHROPIC_API_KEY 和 RABBITMQ_URL

# 将拓扑加载到 RabbitMQ
curl -u guest:guest -X POST \
  http://localhost:15672/api/definitions \
  -H 'Content-Type: application/json' \
  -d @topology.json

启动 worker

打开四个终端窗口,每个窗口启动一个进程:

# 终端 1 —— ReAct agent
python react_agent.py

# 终端 2 —— search tool worker
python tool_worker.py search

# 终端 3 —— calculator tool worker
python tool_worker.py calculator

# 终端 4 —— weather tool worker
python tool_worker.py weather

发送问题

在第五个终端中,向 agent 发送一个测试问题:

python publish_command.py "How tall is the Eiffel Tower in feet?"

# 预期输出:
# Published question: 'How tall is the Eiffel Tower in feet?'
# correlation_id:     3f8a1c2d-...
# Watch react_agent.py logs for the Thought → Action → Observation trace.

在 agent 终端中,你将看到完整的推理轨迹:

2025-10-01 14:22:01 INFO react.agent — [3f8a1c2d] received command
2025-10-01 14:22:01 INFO react.agent — [3f8a1c2d] step=1  THINK
2025-10-01 14:22:02 INFO react.agent — [3f8a1c2d] step=1  → tool=search  input='Eiffel Tower height metres'
2025-10-01 14:22:02 INFO tool.search  — [3f8a1c2d] step=1  input='Eiffel Tower height metres'
2025-10-01 14:22:02 INFO tool.search  — [3f8a1c2d] step=1  output='The Eiffel Tower is 330 metres tall...'
2025-10-01 14:22:02 INFO react.agent — [3f8a1c2d] step=1  ← tool=search  output='The Eiffel Tower is 330 metres tall...'
2025-10-01 14:22:02 INFO react.agent — [3f8a1c2d] step=2  THINK
2025-10-01 14:22:03 INFO react.agent — [3f8a1c2d] step=2  → tool=calculator  input='330 * 3.28084'
2025-10-01 14:22:03 INFO tool.calculator — [3f8a1c2d] step=2  input='330 * 3.28084'
2025-10-01 14:22:03 INFO tool.calculator — [3f8a1c2d] step=2  output='1082.6772'
2025-10-01 14:22:03 INFO react.agent — [3f8a1c2d] step=2  ← tool=calculator  output='1082.6772'
2025-10-01 14:22:03 INFO react.agent — [3f8a1c2d] step=3  THINK
2025-10-01 14:22:04 INFO react.agent — [3f8a1c2d] step=3  FINAL ANSWER
2025-10-01 14:22:04 INFO react.agent — [3f8a1c2d] DONE  answer='The Eiffel Tower is approximately 1,083 feet tall.'

correlation_id [3f8a1c2d] 会出现在参与回答这个问题的每个进程的每一行日志中。这就是 Correlation Identifier 模式在发挥作用:一个 UUID 贯穿 agent、search worker 和 calculator worker,使完整因果链仅凭日志就可以被重建。

观察死信队列

为了理解故障是如何被处理的,你可以触发一个受控故障,并观察消息如何经过 retry 和 quarantine 队列。要观察 DLQ 链条,可以先停止 search worker,然后发送一个需要 Web 搜索的问题:

# 停止 search worker(在它的终端中按 Ctrl+C)
python publish_command.py "Who invented the telephone?"

agent 会分发一个 search 工具请求。没有 worker 会消费它。30 秒后,react.tool.search.retry1.queue 上的 request TTL 过期,消息会 dead-letter 到 Tier 2。再过 5 分钟,它会 dead-letter 到 react.quarantine.queue

与此同时,agent 对 results queue 的 basic_get 轮询会在 30 秒后超时。agent 会 NACK 该 command,command 会 dead-letter 到 react.commands.retry1.queue。30 秒后它会过期进入 retry2,再过 5 分钟进入 quarantine。

你可以在 RabbitMQ management UI 中观察这一点:http://localhost:15672。观察 react.commands.retry1.queuereact.tool.search.retry1.queue 上的消息数增加,然后流入 retry2 队列,最后流入 react.quarantine.queue

重启 search worker。quarantine 中的消息不会自动重放——它们需要 operator 手动干预。要重放这些消息:

# 使用 rabbitmqadmin 或 management API,
# 将消息从 quarantine shovel 回 commands exchange。

# 通过 management API(使用原始 header 重新发布原始 body):
curl -u guest:guest -X POST \
  'http://localhost:15672/api/queues/%2Freact/react.quarantine.queue/get' \
  -H 'Content-Type: application/json' \
  -d '{"count": 1, "ackmode": "ack_requeue_false", "encoding": "auto"}'

检查 x-death header,以理解故障历史,然后把 message body 和原始 header 一起重新发布到 react.commands.exchange

quarantine queue 是一个暂存区,不是死胡同。那里的每条消息都保留了完整的 x-death header 链:原始 queue、reason、count,以及它经过每一层时的 timestamp。这些信息足以诊断消息为什么失败,并决定是重放还是丢弃。

下面的表总结了该实现中的关键设计选择及其背后的理由:

决策选择原因
LLM 响应格式通过 system prompt 强制严格 JSON schema支持确定性解析;非 JSON 可以立即被检测为 poison
工具分发传输RabbitMQ topic exchange,而不是 HTTPdurability、retry 和 DLQ 可直接获得;工具 worker 可独立扩展
结果收集使用带 correlation_id 过滤的 basic_get 轮询对每个问题一个 agent 的情况简单且正确;高并发时可替换为按 correlation 独立 reply queue
故障分类agent 代码在日志中标记故障类型;broker 通过 DLX 路由将 DLQ 路由逻辑保留在拓扑中,而不是散落在应用代码里
工具错误传播ValueError 返回 error ToolResult 并 ACK;未预期错误返回 NACK已知工具错误变成 LLM 可以推理的 Observation;未知错误进入 retry 基础设施
步骤限制MAX_STEPS=10 环境变量防止无限推理循环;耗尽后 NACK 到 DLQ,使问题不会被静默丢弃
Consumer tagagent / tool 类型 + UUID 后缀让 consumer 身份在 management UI 和日志中可见,无需配置文件

表 9.3 —— 设计决策与权衡

到这里,你已经拥有了一个可工作的系统,它具备清晰的消息流、故障处理和可观测性。下一步是考虑,要让这个设置在生产环境中可靠运行,还需要做哪些改变。

为生产环境准备系统

当前实现是为了清晰性和实验性而设计的。在将其用于生产环境之前,需要考虑以下改进:

用真实 API client 替换工具 stub: 为每个外部调用添加 timeout 和 circuit breaker。

用按 correlation 独立 reply queue 替换 basic_get 轮询: agent 在启动时声明一个 exclusive、auto-delete queue,将其名称发布到 reply_to,工具 worker 将结果直接发布到该队列。这可以消除轮询循环和 correlation_id 过滤开销。

持久化最终答案: 当前实现只记录答案日志,并不会把答案发布到任何地方。可以添加一个 react.answers.exchange,以及一个下游 consumer,将答案按 correlation_id 存入数据库。

在所有 basic_publish 调用上添加 publisher confirms: 如果没有 confirms,当 broker 在 publish 与写入磁盘之间重启时,已发布消息可能会静默丢失。

显式设置 x-failure-type header: 当前代码只在日志中记录故障类型,但没有把它们设置为 AMQP header。对 permanent failure 设置 x-failure-type: permanent,可以让拓扑级策略跳过 retry queue,直接路由到 quarantine。

添加 TLS:RABBITMQ_URL 设置为 amqps:// 并配置证书。对于生产环境中的服务间连接,强烈建议使用 mTLS。

监控 quarantine queue 深度: 任何消息进入 react.quarantine.queue 都应该触发 operator 告警。将该队列连接到 monitoring exchange,并配置 alerting consumer。

参数化 Anthropic 模型: react_agent.py 中的 claude-opus-4-5 是硬编码的。应该将它移到环境变量中,这样你可以在不改代码的情况下切换到更快或更便宜的模型。

考虑这些改进之后,系统就会从一个可工作的原型,演进为一个能够在生产环境中运营和扩展的设计。

总结

本章把 ReAct 模式实现为一个基于 RabbitMQ 的分布式 Python 系统。agent、工具 worker 和 publisher 都是独立进程,并且完全通过 broker 连接。它们之间没有直接网络依赖——broker 就是这个系统。

该设计遵循了上一章确立的规则:持久队列、手动确认、每条消息上的 correlation ID、带命名 routing key 的 topic exchange,以及每个队列上的三层 DLQ 链。这些都不是 RabbitMQ 样板代码;每一项之所以存在,都是因为某种具体故障模式需要它。

ReAct 模式是最简单的智能体循环。下一章会把它扩展到 Plan-and-Execute:一个 orchestrator 会把问题分解成并行子任务,把它们分发给专门的 agent,并使用 Scatter-Gather 模式聚合它们的结果。