Agent 用户连续输入解决

目录

这是个非常真实、而且必须在架构层解决的问题。

你这个场景本质上不是“LLM 回复慢”,而是:

用户输入流是异步连续到达的,但 Agent 工作流是有状态、长执行链的。

尤其你用的是 LangGraph,它本身就适合做这类有状态、多步骤、可中断的 Agent,所以这个问题应该在会话编排层处理,而不是只在前端“禁用输入框”。


一、先说结论

对于导购智能体,用户在服务端未回复完时连续发消息,不要简单按“每条消息都立刻启动一个新图”处理,否则一定会乱。

正确做法通常是:

核心原则

  1. 一个会话 thread 只允许一个 active graph run
  2. 新消息到达时,先进入 消息缓冲区(inbox / queue)
  3. 根据策略判断:
    • 打断当前执行
    • 合并到当前轮次
    • 排队等当前执行完成后再处理
  4. 对“补充说明 / 修正前文 / 否定前文”这类消息,要优先视为 override / amend(覆盖/修正)
  5. 对“新问题”这类消息,可以排队成下一轮 turn

一句话:

你要在 LangGraph 外面加一层“会话调度器(Conversation Orchestrator)”。


二、为什么这是个大问题?

假设用户先发:

“我想买个送老婆的生日礼物,预算 2000。”

Agent 正在:

  • 识别旅程:探索期
  • 做推荐
  • 调商品检索工具

这时用户又发:

“不是老婆,是送妈妈。”
“而且预算 1000 以内。”
“最好实用一点。”

如果你不做控制,会发生这些灾难:

灾难 1:并发两个 graph run

第一个 run 还在按“送老婆、预算 2000”推荐。
第二个 run 又按“送妈妈、预算 1000”跑。
最后两个回复交错回来,用户直接懵。

灾难 2:状态污染

第一个 run 把 state 里写入:

  • recipient=wife
  • budget=2000

第二个 run 又写:

  • recipient=mother
  • budget=1000

如果没做好版本控制,state 会互相覆盖。

灾难 3:工具浪费

前一个 run 可能已经调用了 RAG、推荐 API、库存服务。
但这些已经不再有意义。


三、最推荐的总体架构

你可以把整个系统分成 4 层:

[1] WebSocket / IM 接入层
        ↓
[2] 会话调度器 Conversation Orchestrator
        ↓
[3] LangGraph Agent Runtime
        ↓
[4] 工具层 / RAG / 推荐系统 / 下单系统

其中最关键的是第 2 层。


四、会话调度器要做什么?

它负责管理每个用户会话的这几个东西:

1. 当前是否有运行中的 graph

比如:

  • thread_id = user_123_session_001
  • active_run_id = run_abc
  • status = RUNNING

2. 消息缓冲区

例如:

[
  {"msg_id":"m2","text":"不是老婆,是送妈妈","ts":1711111111},
  {"msg_id":"m3","text":"预算1000以内","ts":1711111112},
  {"msg_id":"m4","text":"最好实用一点","ts":1711111113}
]

3. 当前轮次状态

例如:

  • 当前处于探索期
  • 当前正在做推荐
  • 当前是否已经调用了工具
  • 当前回复是否可中断

4. 中断策略

决定新消息来了以后怎么处理。


五、你需要定义 3 种新消息处理策略

这个最核心。


策略 A:Append(追加)

适用于:

  • 用户只是补充一点细节
  • 不改变当前任务主方向
  • 当前 run 即将完成,不值得打断

例如:

“最好轻一点”
“颜色别太花”

这种可以先放入 pending queue,等当前回复结束后,下一轮再合并处理。


策略 B:Amend / Merge(修正合并)

适用于:

  • 用户是在补充和修正前文
  • 本质上还是同一个任务
  • 但约束发生变化

例如:

“不是送老婆,是送妈妈”
“预算不是 2000,是 1000”
“不是手机,是耳机”

这类消息应该被识别为:

对当前用户意图的修正

此时建议:

  • 标记当前 run 为 stale(过期)
  • 触发中断
  • 把最近几条连续消息做 merge,生成新的 user turn
  • 用新的 merged input 重跑 graph

策略 C:Fork / Next Turn(下一轮)

适用于:

  • 用户发的是一个新问题
  • 和当前任务不完全一致
  • 不一定要中断当前流程

例如:

“对了,你们支持花呗吗?”
“顺便问下这个多久发货?”

这类有两种处理方式:

  • 如果当前已进入决策期,可作为下一轮继续
  • 如果是关键交易问题,也可以中断并切换

六、最实用的判断标准:把新消息分成 4 类

建议你在会话调度器里做一个轻量分类器,把新消息分成:

1. correction(纠正)

典型词:

  • 不是
  • 改成
  • 说错了
  • 更正一下
  • 不对
  • 应该是

例子:

  • “不是送老婆,是送妈妈”
  • “预算改成1000”

2. supplement(补充)

典型词:

  • 另外
  • 还有
  • 补充一下
  • 最好
  • 顺便说下

例子:

  • “最好适合 50 岁女性”
  • “最好别太贵”

3. cancel / restart(取消/重来)

典型词:

  • 算了
  • 不用了
  • 重新推荐
  • 换一种
  • 重来

例子:

  • “算了,重新推荐吧”
  • “不要这个方向了”

4. new_intent(新意图)

例子:

  • “怎么退货?”
  • “这个能开发票吗?”

七、推荐的处理逻辑

你可以设计成这样:

如果当前没有 active run:
    直接启动 graph

如果当前有 active run:
    对新消息做分类
        - correction / cancel:中断当前 run,合并消息,重启
        - supplement:短暂缓冲(debounce),合并后决定是否重启
        - new_intent:进入 next_turn_queue

八、为什么要加 debounce(防抖)?

因为用户连续发消息时,通常不是只发一条,而是会连发 2~5 条。

比如:

“不是老婆”
“是妈妈”
“50岁左右”
“预算1000”
“最好实用”

如果你每来一条都立即中断并重跑 graph,系统会疯狂抖动。

所以建议你做一个 300ms ~ 1500ms 的短暂聚合窗口


推荐做法

当检测到用户在连续输入时:

  • 第一条新消息到达:先不立刻重跑
  • 开一个短 timer,比如 800ms
  • 如果期间又来了新消息,就继续收集
  • 800ms 内无新消息,再把这批消息 merge 成一个“修正版输入”

比如最后合并成:

“更正:不是送老婆,是送妈妈,50岁左右,预算1000以内,最好实用。”

然后只触发一次 graph restart。


九、在 LangGraph 里怎么落地?

LangGraph 本身擅长:

  • 有状态图
  • 持久化 state
  • checkpoint
  • interrupt/resume
  • thread 级执行

所以你可以这样设计。


1. state 设计

你的会话 state 不只是 messages,还要有运行元信息:

class ShoppingState(TypedDict):
    messages: list
    user_profile: dict
    journey_stage: str
    current_intent: str
    current_slots: dict
    active_run_id: str
    run_version: int
    pending_user_msgs: list
    is_interrupted: bool

2. 每次 run 带 version

比如:

  • 第一次运行:run_version = 1
  • 用户修正后重启:run_version = 2

这样 graph 节点在执行工具调用后,可以检查自己是否还是最新版本。

例如:

  • 节点启动时记录 version=1
  • 调完推荐 API 回来后发现全局 version 已经变成 2
  • 说明自己过期了,直接放弃输出,不再写回最终回复

这很重要。
因为就算你中断了,有些异步工具调用可能已经发出去了,回包还是会回来。


3. 中断点设计

LangGraph 里不要等整个图跑完才有机会响应新消息。
建议在这些节点后设置“可中断检查”:

  • 意图识别后
  • 工具调用前
  • 工具调用后
  • 最终生成前

也就是每个关键节点都检查一次:

if state["run_version"] != runtime_context.current_run_version:
    return STOP_CURRENT_RUN

4. 输出流式回复时的处理

如果你是流式返回(streaming),用户在模型还在生成时又发新消息,这时建议:

推荐策略

  • 立即停止当前 streaming
  • 前端展示:
    • “已根据你的新补充调整推荐”
  • 后端开始新 run

否则用户会看到:

  • 前半段在讲送老婆
  • 后半段你又切到送妈妈
    体验很差。

十、消息合并要怎么做?

建议不要简单字符串拼接。
应该做“语义合并”。


简单版

用一个小模型/LLM,把连续消息整理成一条结构化修正版输入:

输入:

原始需求:我想买个送老婆的生日礼物,预算2000
后续补充:
1. 不是老婆,是送妈妈
2. 预算1000以内
3. 最好实用一点

输出:

{
  "task": "gift_recommendation",
  "recipient": "mother",
  "age_hint": "50+",
  "budget": "1000以内",
  "style": "实用",
  "replace_previous_constraints": true
}

然后再喂给主 graph。


工业版

直接维护 slot state:

  • recipient
  • age_group
  • budget
  • style
  • category
  • scene

新消息到来时,不是重写整段 messages,而是更新 slots。

例如:

{
  "recipient": "wife" -> "mother",
  "budget": 2000 -> 1000,
  "style": null -> "practical"
}

这对于导购 Agent 很适合,因为它本来就有结构化槽位。


十一、推荐你用“消息层”和“业务状态层”分离

这是重点。

不要把所有逻辑都依赖聊天消息历史。
要分成两层:

1. 消息层

保存原始消息历史,便于审计和回溯。

2. 业务状态层

保存当前导购上下文:

  • 用户旅程
  • 当前商品实体
  • 预算
  • 收礼对象
  • 风格偏好
  • 风险偏好(如果是金融)
  • 当前候选商品列表

这样用户后续补充时,你只更新业务状态,不用每次重放全部对话。


十二、给你一个推荐的状态流转方案

场景 1:正在推荐时,用户修正前文

用户:

  • “送老婆,预算2000”
  • Agent 正在推荐
  • 用户又发:“不是老婆,是送妈妈,预算1000”

处理:

  1. 识别为 correction
  2. 中断当前 run
  3. 合并最近补充消息
  4. 更新 slots
  5. 启动新 run
  6. 前端显示:“收到,我按送妈妈、预算1000重新推荐”

场景 2:正在推荐时,用户补充偏好

用户:

  • “推荐个耳机”
  • 过 1 秒又发:“最好降噪强一点”

处理:

  1. 识别为 supplement
  2. 若当前 run 还没进入最终生成,可选择中断重跑
  3. 若当前 run 已快结束,可把补充作为 next turn,紧接着答

场景 3:正在答一个问题,用户换话题

用户:

  • 正在问礼物推荐
  • 又发:“对了,订单多久能到?”

处理:

  1. 识别为 new_intent
  2. 如果优先级高,可插队
  3. 否则排到 next_turn_queue

十三、前端交互也要配合

后端再强,如果前端没有交互提示,体验也会乱。

建议前端支持这些状态:

1. “正在思考”

显示 Agent 正在处理。

2. “已收到你的补充,正在调整”

当用户在 streaming 中追加消息时,给明确反馈。

3. “已中断上一轮推荐,按最新信息重新处理”

让用户知道系统没有忽略他的新消息。


十四、一个比较推荐的 LangGraph 外围实现方式

你可以在 LangGraph 外包一个 SessionManager


伪代码

class SessionManager:
    def __init__(self):
        self.sessions = {}

    def on_user_message(self, thread_id, message):
        session = self.sessions[thread_id]

        if not session.active_run:
            self.start_new_run(thread_id, [message])
            return

        msg_type = classify_message(message, session)

        if msg_type in ["correction", "cancel"]:
            session.pending_msgs.append(message)
            session.mark_interrupt = True
            self.interrupt_and_restart(thread_id)

        elif msg_type == "supplement":
            session.pending_msgs.append(message)
            self.schedule_debounce_restart(thread_id)

        else:  # new_intent
            session.next_turn_queue.append(message)

restart 逻辑

def interrupt_and_restart(self, thread_id):
    session = self.sessions[thread_id]
    session.run_version += 1
    stop_current_run(session.active_run_id)

    merged_input = merge_messages(session.pending_msgs)
    session.pending_msgs = []

    self.start_new_run(thread_id, [merged_input])

十五、最重要的工程建议

1. 不要让同一 thread 并发跑多个主 graph

这是第一原则。

2. 任何异步工具返回都要检查 run_version

否则旧结果会污染新状态。

3. 对连续消息做 debounce 聚合

否则系统会频繁抖动。

4. 做 correction / supplement / new_intent 分类

不同类型处理方式不同。

5. 导购场景优先维护结构化 slots

因为很多连续消息本质是在修正 slots。


十六、最适合导购 Agent 的一句架构总结

对于导购智能体,用户连续发消息时,不要把每条消息都当成独立 turn,而要把它们看成:

对当前购买任务的动态修订流

所以你的系统应该是:

  • 消息级接收
  • 会话级调度
  • 状态级合并
  • 图级重启/中断

而不是“来一条跑一次 LangGraph”。


如果你愿意,我可以下一步直接给你两样特别实用的东西:

  1. 基于 LangGraph 的会话调度架构图
  2. 一套可直接落地的 Python 伪代码/代码骨架
    • SessionManager
    • 消息分类器
    • debounce
    • run_version 中断机制
    • LangGraph state 定义

如果你要落地,我建议我下一条直接给你 代码骨架版

posted @ 2026-03-25 08:15  向着朝阳  阅读(7)  评论(0)    收藏  举报