Agent 越做越久,主对话却不能卡死:LangChain 异步子任务实战与迟到回报排障

用户让 Agent 研究一个小时,主对话就一直没有回应。

用户中途取消任务,后台回报却又把结果写回来。

同一份结果到达两次,界面出现重复卡片。

这不是简单加一个线程就能解决的问题。

LangChain 在 2026 年 11 月 5 日发布的Agents Are Going Async把一个趋势讲得很清楚:Agent 需要一边保持对用户响应,一边让较长任务在后台推进。

本文不复述托管产品内部实现,而是把这个问题缩小为一套可验证的工程协议:任务 ID、执行代次 generation、状态条件更新和终态幂等。

你会得到三张原创图、一份 Python+SQLite 教学程序,以及取消竞争、重复回报和重启恢复的最小复现。

边界说明:代码只使用标准库,不启动真实后台进程,不连接 LangChain 云服务、模型、队列或消息渠道。实验验证的是状态控制逻辑,不是异步吞吐、模型质量或产品性能。

图 1:异步编排把提交、执行和回报拆成生命周期;主 Agent 可以继续回应用户,但每次回报都必须经过控制面核对。

一、为什么异步 Agent 不是“把函数丢到后台”

同步 Agent 的隐含假设是:一次调用开始后,当前执行者会拿到结果再继续。

这个假设一旦被后台任务打破,就会出现三个新的时间关系:用户的新消息可能先到,取消操作可能先到,旧任务结果可能最后才到。

如果系统只保留一个任务名,无法判断最后到达的结果属于哪一轮执行;如果系统只保存内存 Future,进程重启后又不知道任务处于什么状态。

因此需要把任务变成持久化记录,并明确三类身份:

  • task_id 表示哪项工作。
  • generation 表示这项工作的哪一轮执行上下文。
  • state 表示控制面当前是否仍允许写入。

LangChain 官方文章把异步子 Agent 描述为:编排 Agent 继续与用户交互,后台子 Agent 完成后再回报;本文的 generation 和状态机是针对这一问题的独立工程设计,不是官方 API 的逐字实现。

二、先画状态机,再决定回报是否有效

图 2:任务名相同并不足以接收结果;回报必须同时匹配任务、执行代次和当前状态。

本例存储四种状态,另将旧代次回报标识为 STALE 概念:

状态 含义 允许的下一步
SUBMITTED 控制面已接收任务 启动为 RUNNING,或取消
RUNNING 后台工作进行中 成功回报,或取消
SUCCEEDED 已确认一次结果 终态,拒绝重复回报
CANCELLED 已取消本轮执行 终态,拒绝旧回报
STALE 本文不单独存储 用 generation 不匹配表达旧回报

取消时将 generation 加一,是为了让旧执行器即使带着正确的 task_id 回来,也无法再匹配原来的代次。

关键更新可以写成:

UPDATE tasks
SET state = 'SUCCEEDED', result = :result
WHERE id = :task_id
  AND generation = :generation
  AND state = 'RUNNING';

只有更新 1 行才表示回报赢得状态转换;更新 0 行时,调用方应重新读取任务,区分已取消、已完成、代次过期或任务不存在。

这一步要放在数据库条件更新里,而不是先在 Python 里读状态、再无条件写回。后者在两个执行者竞争时会留下时间窗口。

三、最小实验:取消后的迟到回报会怎样?

环境是 Python 3.12.3 和 SQLite 3.45.1,保存下面代码为 async_demo.py,直接运行:

python async_demo.py
"""标准库教学实验:用数据库条件更新模拟异步 Agent 任务回报。"""
import sqlite3, tempfile
from pathlib import Path

class Tasks:
    def __init__(self, path):
        self.db=sqlite3.connect(path)
        self.db.execute("CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, generation INTEGER, state TEXT, result TEXT)")
        self.db.commit()
    def submit(self, task):
        with self.db:self.db.execute("INSERT INTO tasks VALUES(?,1,'SUBMITTED',NULL)",(task,))
    def start(self, task, gen=1):
        with self.db:return self.db.execute("UPDATE tasks SET state='RUNNING' WHERE id=? AND generation=? AND state='SUBMITTED'",(task,gen)).rowcount==1
    def cancel(self, task, gen=1):
        with self.db:return self.db.execute("UPDATE tasks SET state='CANCELLED',generation=generation+1 WHERE id=? AND generation=? AND state IN ('SUBMITTED','RUNNING')",(task,gen)).rowcount==1
    def report(self, task, gen, result):
        with self.db:
            ok=self.db.execute("UPDATE tasks SET state='SUCCEEDED',result=? WHERE id=? AND generation=? AND state='RUNNING'",(result,task,gen)).rowcount==1
        return ok
    def row(self, task):return self.db.execute("SELECT * FROM tasks WHERE id=?",(task,)).fetchone()
    def close(self):self.db.close()

def demo():
 with tempfile.TemporaryDirectory() as tmp:
  q=Tasks(Path(tmp)/'tasks.db');q.submit('research');assert q.start('research');print('PASS submit/start: RUNNING')
  assert q.cancel('research');assert not q.report('research',1,'late');print('PASS cancel: old generation rejected')
  q.submit('second');assert q.start('second');assert q.report('second',1,'ok');assert not q.report('second',1,'duplicate');print('PASS report: one terminal transition')
  q.submit('third');assert q.start('third');q.db.close();q=Tasks(Path(tmp)/'tasks.db');assert q.row('third')[2]=='RUNNING';assert q.report('third',1,'after-restart');print('PASS restart: task state persisted')
  q.close()
if __name__=='__main__':demo()

实际输出:

PASS submit/start: RUNNING
PASS cancel: old generation rejected
PASS report: one terminal transition
PASS restart: task state persisted

图 3:四组边界检查验证提交/启动、取消与迟到回报、终态幂等和重开连接。没有启动真实异步工作进程。

实验一:取消竞争

任务 research 从 SUBMITTED 进入 RUNNING。

取消操作匹配 generation=1,成功后把 generation 改成 2,并把状态写成 CANCELLED。

后台迟到回报仍携带 generation=1,因此条件更新匹配不到,report() 返回 False。

取消不是给后台线程发一条“请尽快停止”的提示,而是先让控制面拒绝旧代次写入。

真实系统仍要向执行器发送取消信号、回收资源和记录取消原因;本实验只验证回报不会覆盖控制状态。

实验二:重复回报

任务 second 首次回报把 RUNNING 改为 SUCCEEDED。

第二次回报仍携带 generation=1,但当前状态已经不是 RUNNING,因此更新 0 行。

这让回报接口具备终态幂等:重试可以安全返回“已处理”或“状态已变化”,而不是再写一份结果。

实验三:重启后继续

任务 third 在 RUNNING 时关闭 SQLite 连接,再用同一文件重新打开。

状态仍为 RUNNING,随后可以用同一代次提交教学结果。

这只证明状态不依赖当前 Python 连接;没有测试进程崩溃、租约过期、队列重投、多个 Worker 或跨机器数据库锁。

四、主 Agent 如何与后台任务交互?

建议把一次用户请求拆成两个方向:

  1. 主 Agent 负责解释任务已接收、返回 task_id、处理用户新消息和取消请求。
  2. 后台执行器负责执行工作,结束后提交 task_id + generation + result。
  3. 控制面负责核对状态与代次,决定是否接受结果,再通知主 Agent。

主 Agent 不应该因为后台任务尚未完成就阻塞下一次用户输入。

后台结果也不应直接写进对话历史;先通过控制面落库,确认有效后再生成一条用户可见更新。

如果用户已经发起取消,界面可以立即显示“取消处理中”,但只有数据库成功转为 CANCELLED,系统才应阻止旧结果覆盖。

如果后台任务已完成但主 Agent 当前处于忙碌、空闲或等待人工批准,不同产品可以采用不同唤醒策略;这属于调度协议,不能从本地 SQLite 实验推导出来。

五、排障:为什么会出现重复结果或结果丢失?

现象 先查什么 可能的修正
取消后仍显示成功 回报代次、取消时间、状态更新行数 取消时推进 generation,回报做条件更新
同一结果出现两次 回报是否有稳定幂等键、终态是否可重复写 终态只允许 RUNNING→SUCCEEDED 一次
重启后任务消失 状态是否只保存在内存 Future 把任务生命周期持久化
主对话卡住 编排器是否同步等待后台结果 立即返回 task_id,把结果回报变成事件
后台完成但用户没看到 控制面已确认,通知是否失败 分离任务结果与用户通知,保留重试和 UNKNOWN
旧任务覆盖新任务 只按 task_id 匹配 增加 generation 或执行租约

最容易遗漏的是“结果已确认”和“用户已看到”不是同一件事。

控制面可以把任务记为 SUCCEEDED,但消息渠道可能尚未投递;如果还要保证用户最终看到,就需要另一个通知状态和渠道回执,不能把本例的 result 字段当成端到端送达证明。

六、和已有教程怎样配合?

浏览器 Agent 登录交接教程重点是人工介入、页面状态和外部动作未知;本文重点是后台任务生命周期和旧代次回报。

主动通知治理教程重点是事件去重、主题合并和通知批次预算;本文的任务回报可以作为通知治理的上游输入。

如果你把三者组合起来,可以形成一条更完整的控制链:后台任务先用 task_id 与 generation 保证结果不串轮次,再用事件 ID 和预算决定是否通知用户,最后在浏览器动作前重新核验页面和授权。

每一层都有自己的状态和证据,不能用某一层的成功替代另一层的成功。

七、上线前做四个无副作用测试

在不触发真实外部动作的环境里,先提交一个长任务,然后依次测试:用户继续发消息、用户取消、旧回报迟到、同一回报重试。

验收重点不是“模型是否听懂取消”,而是:

  • 取消后旧 generation 的更新行数为 0。
  • 同一终态重复回报不会追加第二份结果。
  • 进程或连接重开后,任务状态仍可解释。
  • 用户通知失败时,任务结果和通知状态不会互相覆盖。

如果这些条件尚未满足,先不要给 Agent 增加更多后台工具或更长的目标;异步能力会放大原本不清晰的状态边界。


来源与实验说明:热点来源为 LangChain 官方文章 Agents Are Going Async,发布日期 2026-11-05,核对日期 2026-11-10。本文状态机、代码与三张图为社区原创,使用 Python 3.12.3+SQLite 3.45.1 实际运行并通过四组边界检查;未测试托管产品、真实异步进程、模型、队列、并发、认证、渠道投递、费用或搜索排名,不含商业推广内容。