当 ReAct Agent 遇上状态机:LangGraph 在会议室预定系统中的工程化实践
当 ReAct Agent 遇上状态机:LangGraph 在会议室预定系统中的工程化实践
本文以一个真实的会议室预定系统为蓝本,深入剖析如何将 ReAct(Reasoning + Acting)范式与有限状态机融合,借助 LangGraph 构建出可中断、可恢复、可观测的多轮对话 AI Agent。
一、为什么不用传统的 Chain?
在 LLM 应用开发中,LangChain 的 Chain 模式(如 LLMChain、SequentialChain)是最常见的编排方式。但它有一个根本性的缺陷:线性执行,无法中断。
会议室预定是一个典型的多轮交互任务:
用户: "明天下午2点,8人会议室"
AI: "找到3间可用会议室,请选择..." ← 需要等待用户
用户: "选1"
AI: "确认预定会议室A?" ← 又需要等待用户
用户: "确认"
AI: "预定成功!"
这个流程中有两次主动中断(等待用户选择、等待用户确认),传统的 Chain 无法表达这种"执行到一半暂停,等用户回复后再继续"的逻辑。
而 LangGraph 提供了两个关键原语来解决这个问题:
- StateGraph:用有向图定义节点和边,支持条件路由
- Interrupt:在任意节点后中断执行,通过 checkpoint 持久化状态,下次从断点恢复
二、状态机设计:五阶段有限状态机
在设计 Agent 之前,先要明确业务的状态流转。我们将会议室预定拆分为 5 个阶段:
COLLECTING_SLOTS → SEARCHING → AWAITING_SELECTION → CONFIRMING → BOOKED
(收集槽位) (查询中) (等待选择) (确认中) (已预订)
用 Python Enum 表达:
class Phase(Enum):
COLLECTING_SLOTS = "COLLECTING_SLOTS" # 收集槽位
SEARCHING = "SEARCHING" # 查询中
AWAITING_SELECTION = "AWAITING_SELECTION" # 等待选择
CONFIRMING = "CONFIRMING" # 确认中
BOOKED = "BOOKED" # 已预订
槽位(Slot)设计
预定会议室需要收集的关键信息称为槽位,分为必需和可选两类:
| 槽位 | 类型 | 是否必需 | 说明 |
|---|---|---|---|
date |
YYYY-MM-DD | 必需 | 只允许明天或后天 |
start_time |
HH:MM | 必需 | 会议开始时间 |
duration_minutes |
int | 必需 | 只能是 30/60/90/120 |
capacity |
int | 必需 | 只能是 8 或 10 |
building |
str | 可选 | 楼栋偏好 |
organizer_id |
str | 可选 | 组织者标识 |
为什么 capacity 只能是 8 或 10? 这是业务层面的简化约束。本项目的 Mock 环境中,公司会议室只有两种规格:8人小会议室和10人中会议室。
validate_and_restrict_slots函数会校验用户输入并将其约束到合法值域。这是为了演示"槽位校验与约束"机制而人为设定的业务规则,并非技术限制——在生产环境中,这个约束应该来自真实的会议室资源数据。
必需槽位缺一不可,这是 LLM 决策"是否该调用搜索工具"的核心判断依据。
三、BookingContext:状态的单一数据源
所有节点共享同一个 BookingContext 对象,它是整个流程的单一数据源(Single Source of Truth):
@dataclass
class BookingContext:
session_id: str # 会话唯一标识
turn_id: int = 0 # 当前对话轮次
all_infos: Dict[str, Any] = field(...) # 槽位、候选房间、选中房间等
messages: List[Dict[str, str]] = field(...) # 完整对话历史
actions_executed: List[Dict] = field(...) # 已执行的动作记录(只增不删)
pending_user_kind: Optional[str] = None # 等待用户输入的类型
pending_prompt: Optional[str] = None # 等待用户的提示文本
done: bool = False # 是否完成
last_tool_error: Optional[str] = None # 最后的工具错误
关键设计决策:
all_infos用 Dict 而非固定字段:LLM 和工具节点都可以自由扩展信息,避免频繁修改数据类定义。actions_executed只增不删:这是 ReAct 中"Thought → Action → Observation"的完整记录,用于 LLM 规划时感知历史上下文。pending_user_kind是中断信号:当它为None时表示不需要用户输入,为clarify/select_room/confirm时触发中断。
四、LangGraph 图编排:节点、边与条件路由
4.1 图的拓扑结构
ingest → react_planner → [条件路由]
├── ask_user → await_user → (interrupt)
├── search_rooms → search_rooms → await_user → (interrupt)
├── book_room → book_room → END
└── finish → END
5 个节点,1 个条件路由,1 个中断点。
4.2 节点职责划分
每个节点只做一件事,遵循单一职责原则:
| 节点 | 类型 | 职责 |
|---|---|---|
ingest |
代码节点 | 解析用户消息,提取槽位/选择/确认 |
react_planner |
LLM 节点 | 基于 ReAct 范式规划下一步动作 |
search_rooms |
工具节点 | 调用 API 查询可用会议室 |
book_room |
工具节点 | 调用 API 预定并写入数据库 |
await_user |
代码节点 | 设置中断信号,准备中断 |
4.3 图构建代码
def build_graph():
workflow = StateGraph(GraphState)
# 注册节点
workflow.add_node("ingest", ingest_node)
workflow.add_node("react_planner", react_planner_node)
workflow.add_node("await_user", await_user_node)
workflow.add_node("search_rooms", search_rooms_node)
workflow.add_node("book_room", book_room_node)
# 入口
workflow.set_entry_point("ingest")
# 固定边:ingest → react_planner
workflow.add_edge("ingest", "react_planner")
# 条件路由:react_planner 根据 LLM 决策选择分支
workflow.add_conditional_edges(
"react_planner",
route_action,
{
"ask_user": "await_user",
"search_rooms": "search_rooms",
"book_room": "book_room",
"finish": END
}
)
# 工具执行后回到 await_user 等待用户
workflow.add_edge("search_rooms", "await_user")
workflow.add_edge("book_room", END)
# 编译:配置检查点 + 中断点
checkpointer = MemorySaver()
app = workflow.compile(
checkpointer=checkpointer,
interrupt_after=["await_user"] # 关键:在 await_user 之后中断
)
return app
4.4 条件路由函数
路由函数是图的"大脑",它读取 LLM 的规划结果来决定走哪条路:
def route_action(state: GraphState) -> Literal["ask_user", "search_rooms", "book_room", "finish"]:
ctx = state['ctx']
# 安全兜底:如果已完成预订,直接结束
if ctx.done:
return 'finish'
last_action = ctx.all_infos.get('last_action', {})
action = last_action.get('action', 'ask_user')
# 未知动作的安全降级
if action not in ['ask_user', 'search_rooms', 'book_room', 'finish']:
return 'ask_user'
return action
注意两个防御性设计:
ctx.done检查优先于 action 判断,防止已完成预订后还去执行其他动作。- 未知 action 降级为
ask_user而非抛异常,保证图的鲁棒性。
五、ReAct Planner:LLM 如何做出决策
5.1 Prompt 工程
ReAct Planner 是整个系统的"大脑"。我们设计了一个结构化的 Prompt,包含 5 个动态注入段:
REACT_PLANNER_PROMPT = """你是一个会议室预定助手,使用 ReAct 模式进行规划。
## 当前日期
{current_date}
## 当前状态(包含已收集的槽位信息)
{all_infos}
## 最近执行的动作历史
{actions_executed}
## 用户最新消息
{user_message}
## 可用动作
- ask_user: 询问用户缺失的信息
- search_rooms: 查询可用会议室(需要完整槽位)
- book_room: 预订会议室(需要先选择房间)
- finish: 完成对话
## 重要规则
1. 仔细检查 all_infos.slots 中的值,不为 null 的表示已经收集到了
2. 只有当所有必需槽位都有值时,才能使用 search_rooms
3. 如果某个必需槽位是 null,使用 ask_user 询问用户
...
## 输出格式
请以 JSON 格式输出:
- thought: 你的思考过程
- action: 要执行的动作
- action_input: 动作的输入参数
"""
5.2 Thought 的价值
很多人觉得让 LLM 输出 thought 是浪费 Token。但在 ReAct 范式中,Thought 是推理链(Chain of Thought)的具象化。它有两个关键作用:
- 强制 LLM 自检:要求 LLM 在 thought 中列出"哪些必需槽位已有值,哪些还是 null",这大幅减少了 LLM 在槽位不完整时就去调用 search_rooms 的错误。
- 调试可追溯:当 Agent 做出错误决策时,查看 thought 字段就能知道 LLM 的推理过程是哪里出了问题。
5.3 JSON 解析的容错处理
LLM 的输出不一定总是合法的 JSON。我们实现了三级降级解析:
def parse_llm_response(response: str) -> dict:
# 第一级:直接解析
try:
return json.loads(response)
except json.JSONDecodeError:
pass
# 第二级:提取 JSON 块(LLM 有时会在 JSON 前后加 markdown 标记)
try:
start_idx = response.find('{')
end_idx = response.rfind('}') + 1
if start_idx != -1 and end_idx != 0:
return json.loads(response[start_idx:end_idx])
except Exception:
pass
# 第三级:降级为 ask_user,让系统继续运行
return {
'thought': '无法解析 LLM 响应',
'action': 'ask_user',
'action_input': {'message': '抱歉,我没有理解您的意思,请重新输入'}
}
核心原则:宁可多问用户一句,也不能让系统崩溃。
六、Ingest 节点:规则与 LLM 的协作
一个有意思的设计决策是:槽位提取不完全依赖 LLM。
ingest 节点用正则表达式做第一层提取:
def parse_slots(message: str, current_slots: dict) -> dict:
slots = current_slots.copy()
# 日期:正则匹配"明天"/"后天"
if '明天' in message:
slots['date'] = (date.today() + timedelta(days=1)).isoformat()
elif '后天' in message:
slots['date'] = (date.today() + timedelta(days=2)).isoformat()
# 人数:正则匹配"8人"
capacity_match = re.search(r'(\d+)人', message)
if capacity_match:
slots['capacity'] = int(capacity_match.group(1))
# 时间:正则匹配"下午2点"
time_match = re.search(r'(\d{1,2})点', message)
if time_match:
hour = int(time_match.group(1))
if '下午' in message and hour < 12:
hour += 12
slots['start_time'] = f"{hour:02d}:00"
return slots
为什么不全部交给 LLM?
- 确定性:正则提取是确定性的,"明天下午2点8人"提取出来的结果 100% 正确,不需要 LLM 判断。
- 速度:正则提取耗时 < 1ms,LLM 调用耗时 1-5s。
- 成本:少一次 LLM 调用就少一份 Token 消耗。
LLM 的角色是兜底和推理:当正则无法提取(比如用户说"下周三"),或者需要理解上下文(比如用户说"改成10人")时,LLM 的 ask_user 动作会引导对话。
槽位校验与约束
提取之后还有一层业务规则校验:
def validate_and_restrict_slots(slots, old_slots):
# 日期只能是明天或后天
if validated_slots.get('date'):
date_obj = date.fromisoformat(validated_slots['date'])
if date_obj not in [tomorrow, day_after]:
validated_slots['date'] = old_slots.get('date') # 回退
# 人数只能是 8 或 10
if validated_slots.get('capacity'):
if validated_slots['capacity'] not in [8, 10]:
# 就近取合法值
...
# 时长只能是 30/60/90/120
...
这层校验保证了即使 LLM 或正则提取出了不合理的值,系统也不会带着错误参数去调用下游 API。
七、await_user 节点:中断前的最后一步
await_user 是图执行中最后一个节点(在中断之前)。它的职责是根据当前状态生成精确的提示信息:
def run(ctx: BookingContext) -> BookingContext:
if action == 'ask_user':
ctx.pending_user_kind = 'clarify'
message = action_input.get('message', '请提供更多信息')
# 智能补充选项提示
if '时长' in message:
message += "(可选:30分钟、60分钟、90分钟、120分钟)"
if '人数' in message:
message += "(可选:8人、10人)"
ctx.pending_prompt = message
elif ctx.get_phase() == Phase.AWAITING_SELECTION:
ctx.pending_user_kind = 'select_room'
# 生成房间列表...
elif ctx.get_phase() == Phase.CONFIRMING:
ctx.pending_user_kind = 'confirm'
# 生成确认信息...
这里有一个细节:选项提示是动态补充的。如果 LLM 生成的询问消息中已经包含了选项(如"请问持续多长时间?可选30、60、90、120分钟"),代码会检测到已有选项而不再追加。这避免了提示信息的冗余。
八、完整流程串演
以用户说"明天下午2点8人会议室"为例,完整执行流程:
1. [ingest] 正则提取 → date=明天, start_time=14:00, capacity=8
↳ 缺少 duration_minutes,阶段保持 COLLECTING_SLOTS
2. [react_planner] LLM 分析 slots:
↳ thought: "date/start_time/capacity已有,duration_minutes为null"
↳ action: ask_user
↳ action_input: {"message": "请问会议持续多长时间?"}
3. [route_action] 读取 action=ask_user → 路由到 await_user
4. [await_user] 设置 pending_user_kind='clarify'
↳ 补充提示: "(可选:30分钟、60分钟、90分钟、120分钟)"
5. [interrupt] 图中断,返回 WebSocket 响应给前端
↳ 前端显示: "请问会议持续多长时间?(可选:30/60/90/120分钟)"
--- 用户回复 "1小时" ---
6. [ingest] 正则提取 → duration_minutes=60
↳ 所有必需槽位齐全,阶段转为 SEARCHING
7. [react_planner] LLM 分析: 所有必需槽位齐全
↳ action: search_rooms
8. [search_rooms] 调用 API → 返回 3 间候选会议室
↳ 阶段转为 AWAITING_SELECTION
9. [await_user] 设置 pending_user_kind='select_room'
↳ 生成房间列表提示
10. [interrupt] 图中断,等待用户选择
--- 用户回复 "选1" ---
11. [ingest] 解析选择 → selected_room_id=R1001
↳ 阶段转为 CONFIRMING
12. [react_planner] → action: book_room
13. [book_room] 调用 API + 写入 MySQL → done=True
14. [route_action] → finish → END
九、工程经验总结
1. 状态机是 Agent 的骨架
不要试图让 LLM 完全自主决策流转逻辑。有限状态机定义了合法的流转路径,LLM 在约束内做局部最优决策。这种"规则约束 + AI 决策"的混合模式,比纯 AI 驱动可靠得多。
2. 节点要原子化
每个节点只做一件事。ingest 不做 LLM 调用,react_planner 不做 API 请求,search_rooms 不做结果展示。原子化的节点更容易测试、调试和替换。
3. 中断点要精确
interrupt_after=["await_user"] 意味着只有 await_user 节点执行完后才会中断。如果中断点设在 search_rooms 后面,那 LLM 的规划结果就丢失了。中断点的位置决定了 checkpoint 中保存的状态粒度。
4. 防御性编程无处不在
- LLM 输出解析失败?降级为 ask_user
- 路由函数收到未知 action?降级为 ask_user
- 槽位值不合法?回退到旧值
- 数据库写入失败?不阻断主流程
AI Agent 系统的第一原则:永远不要让系统崩溃。
5. Thought 不是浪费 Token
在 ReAct 范式中,强制 LLM 输出推理过程(Thought)可以显著提升决策质量。这是 Chain of Thought 在工程中的实际应用——让 LLM "说出来"再"做决定"。
下一篇:WebSocket 实时通信与 AI Agent 中断恢复机制深度解析 —— 深入剖析 LangGraph 的 checkpoint 机制如何在 WebSocket 场景下实现多轮对话的状态持久化与恢复。
浙公网安备 33010602011771号