上一章介绍了 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。循环如下所示:
图 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 上其他工作负载隔离 |
| exchange | react.commands.exchange | topic —— 接收来自 publisher 的问题 |
| exchange | react.tool.dispatch.exchange | topic —— agent 在这里发布工具请求 |
| exchange | react.tool.results.exchange | topic —— 工具 worker 在这里发布结果 |
| exchange | react.dlq.retry1.exchange | topic —— 第一层重试,30 秒 TTL 队列 |
| exchange | react.dlq.retry2.exchange | topic —— 第二层重试,5 分钟 TTL 队列 |
| exchange | react.dlq.quarantine.exchange | fanout —— 终止型 DLQ,只允许手动重放 |
| queue | react.commands.queue | durable,DLX → retry1 |
| queue | react.tool.search.queue | durable,DLX → retry1 |
| queue | react.tool.calculator.queue | durable,DLX → retry1 |
| queue | react.tool.weather.queue | durable,DLX → retry1 |
| queue | react.tool.results.queue | durable,DLX → retry1,TTL 5 分钟 |
| queue | react.quarantine.queue | durable,无 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。它在命令首次被接收时设置一次,并在同一条推理链中的每个 ToolRequest 和 ToolResult 中保持不变。当 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.JSONDecodeError、ValueError、KeyError | Poison | basic_nack(requeue=False) | → retry1 → retry2 → quarantine |
TimeoutError | Transient | basic_nack(requeue=False) | → retry1,30 秒 → retry2,5 分钟 → quarantine |
RuntimeError,步骤数限制 | Permanent | basic_nack(requeue=False) | → retry1 → retry2 → quarantine |
| 未预期异常 | Unknown | basic_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.queue 和 react.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,而不是 HTTP | durability、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 tag | agent / 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 模式聚合它们的结果。