当 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     # 最后的工具错误

关键设计决策:

  1. all_infos 用 Dict 而非固定字段:LLM 和工具节点都可以自由扩展信息,避免频繁修改数据类定义。
  2. actions_executed 只增不删:这是 ReAct 中"Thought → Action → Observation"的完整记录,用于 LLM 规划时感知历史上下文。
  3. 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

注意两个防御性设计

  1. ctx.done 检查优先于 action 判断,防止已完成预订后还去执行其他动作。
  2. 未知 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)的具象化。它有两个关键作用:

  1. 强制 LLM 自检:要求 LLM 在 thought 中列出"哪些必需槽位已有值,哪些还是 null",这大幅减少了 LLM 在槽位不完整时就去调用 search_rooms 的错误。
  2. 调试可追溯:当 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?

  1. 确定性:正则提取是确定性的,"明天下午2点8人"提取出来的结果 100% 正确,不需要 LLM 判断。
  2. 速度:正则提取耗时 < 1ms,LLM 调用耗时 1-5s。
  3. 成本:少一次 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 场景下实现多轮对话的状态持久化与恢复。

posted @ 2026-06-09 07:38  黄忠  阅读(47)  评论(0)    收藏  举报