LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 6 · Production
1 6-1 Creating
2 6-2 Connecting
3 6-3 Double Texting
4 6-4 Assistant
HomeLangGraphModule 6 · Production6-3 Double Texting
📓 Notebook 📖 Explained
LANGGRAPH MODULE 6 · LESSON 3

Double Texting

用户在上一条消息处理完成前发送新消息——深度讲解生产环境中并发消息的四种处理策略:Reject、Enqueue、Interrupt 与 Rollback。

1 什么是 Double Texting 及为何重要

Double Texting(双重发送)指用户在当前 run 尚未完成时,向同一线程发送了新的消息。在真实的聊天应用中,这种情况极为常见:

LangGraph Server 通过 multitask_strategy 参数提供四种策略,让开发者根据业务场景选择最合适的并发处理方式。

核心问题

同一个线程上,Run 1 还在执行,Run 2 到来时应该怎么办?这四种策略代表了四种不同的权衡取舍。

2 四种策略总览

🚫 Reject

Run 1 进行中时,直接拒绝 Run 2,返回 409 错误。Run 1 正常完成。

📋 Enqueue

Run 2 进入队列等待。Run 1 完成后,Run 2 自动开始执行。两个 run 都会完成。

⏸️ Interrupt

Run 1 在当前节点结束后停止(保留已完成的工作),Run 2 立即开始。Run 1 状态为 interrupted。

🔄 Rollback

Run 1 被立即停止并删除(撤销所有状态),Run 2 从线程的上一个干净状态开始。

3 策略一:Reject(拒绝)

最简单的策略:Run 1 运行时拒绝所有新请求,返回 HTTP 409 Conflict。

import httpx

thread = await client.threads.create()
config = {"configurable": {"user_id": "Test"}}

# 启动 Run 1
run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Add a ToDo to follow-up with DI Repairs.")]},
    config=config,
)

# Run 2 使用 reject 策略 → 触发 HTTPStatusError 409
try:
    await client.runs.create(
        thread["thread_id"], "task_maistro",
        input={"messages": [HumanMessage(content="Add a ToDo to mount dresser to the wall.")]},
        config=config,
        multitask_strategy="reject",
    )
except httpx.HTTPStatusError as e:
    print("Failed to start concurrent run", e)
输出
Failed to start concurrent run Client error '409 Conflict' for url '.../threads/.../runs'

Run 1 正常完成后,线程状态中只包含第一条 ToDo(mount dresser 未被添加)。

适用场景

适合不允许消息丢失可以告知用户"请稍候"的场景。例如:金融交易处理、表单提交。客户端需要捕获 409 并提示用户等待后重试。

4 策略二:Enqueue(排队)

Run 2 不会被拒绝,而是进入等待队列。Run 1 完成后,服务器自动开始执行 Run 2。两个 run 都会执行,消息不丢失。

thread = await client.threads.create()

# Run 1:立即开始
first_run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Send Erik his t-shirt gift this weekend.")]},
    config=config,
)

# Run 2:进入队列,等待 Run 1 完成
second_run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Get cash and pay nanny for 2 weeks. Do this by Friday.")]},
    config=config,
    multitask_strategy="enqueue",
)

# 等待 Run 2 完成(Run 1 → Run 2 串行执行)
await client.runs.join(thread["thread_id"], second_run["run_id"])
执行完成后线程状态(两条消息都被处理)

Human: Send Erik his t-shirt gift this weekend.
AI: I've updated your ToDo list to send Erik his t-shirt gift this weekend.
Human: Get cash and pay nanny for 2 weeks. Do this by Friday.
AI: I've updated your ToDo list to get cash and pay the nanny for 2 weeks by Friday.
适用场景

适合每条消息都必须被处理的场景,且消息之间有逻辑顺序(Run 2 需要看到 Run 1 的结果)。例如:多步骤任务录入、聊天记录保存。注意:如果用户发送的是"补充说明",排队可确保 AI 先处理原始消息再处理补充。

5 策略三:Interrupt(中断)

Run 2 到来时,Run 1 在当前节点执行完毕后暂停(不会立即杀死)。Run 1 已完成的工作被保留到 PostgreSQL,状态变为 interrupted;Run 2 立即开始执行。

import asyncio

thread = await client.threads.create()

# Run 1 开始(查询 ToDo 摘要)
interrupted_run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Give me a summary of my ToDos due tomorrow.")]},
    config=config,
)

# 等 1 秒让 Run 1 执行一部分节点
await asyncio.sleep(1)

# Run 2 使用 interrupt 策略 → Run 1 在当前节点结束后停止
second_run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Never mind, create a ToDo to Order Ham for Thanksgiving by next Friday.")]},
    config=config,
    multitask_strategy="interrupt",
)

await client.runs.join(thread["thread_id"], second_run["run_id"])

# 确认 Run 1 状态
status = (await client.runs.get(thread["thread_id"], interrupted_run["run_id"]))["status"]
print(status)  # "interrupted"
线程最终状态

Human: Give me a summary of my ToDos due tomorrow.
← Run 1 的输入消息被保留(已完成工作),但 AI 回复没有生成(在中间节点被中断)

Human: Never mind, create a ToDo to Order Ham for Thanksgiving by next Friday.
AI: I've added the task "Order Ham for Thanksgiving" to your ToDo list with a deadline of next Friday.
Interrupt vs Rollback 的关键区别

Interrupt 保留 Run 1 已经完成的节点的结果(如果 Run 1 已经写入了 Store,那些写入会保留)。Rollback 则会撤销 Run 1 的一切影响。如果 Run 1 的副作用(写入 DB、发送通知)不可接受,用 Rollback;如果已完成的部分工作有价值,用 Interrupt。

6 策略四:Rollback(回滚)

Run 2 到来时,Run 1 被立即停止并从数据库中删除(包括其所有状态变更)。Run 2 从线程的上一个干净 checkpoint 开始执行,就好像 Run 1 从未发生过一样。

thread = await client.threads.create()

# Run 1:用户的原始意图
rolled_back_run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Add a ToDo to call to make appointment at Yoga.")]},
    config=config,
)

# Run 2:用户改变了主意
second_run = await client.runs.create(
    thread["thread_id"], "task_maistro",
    input={"messages": [HumanMessage(content="Actually, add a ToDo to drop by Yoga in person on Sunday.")]},
    config=config,
    multitask_strategy="rollback",
)

await client.runs.join(thread["thread_id"], second_run["run_id"])

# 验证 Run 1 已被删除
try:
    await client.runs.get(thread["thread_id"], rolled_back_run["run_id"])
except httpx.HTTPStatusError:
    print("Original run was correctly deleted")  # ✓
线程最终状态(只有 Run 2 的消息)

Human: Actually, add a ToDo to drop by Yoga in person on Sunday.
AI: It looks like the task "Drop by Yoga in person" is already on your ToDo list with a deadline of November 19. Would you like me to update the deadline?
← "call to make appointment" 的消息从未出现在线程中(Run 1 被彻底删除)
Rollback 的注意事项

Rollback 会物理删除 Run 1 的记录。如果 Run 1 已经触发了不可逆的外部副作用(如发送邮件、调用外部 API、扣款),这些副作用不会被撤销。Rollback 仅撤销 LangGraph 内部的状态(checkpoints、Store 中的写入)。

7 四种策略对比与选择指南

策略Run 1 结果Run 2 结果线程历史适用场景
Reject 正常完成 ❌ 被拒绝(409) 仅 Run 1 不允许并发,客户端能处理重试
Enqueue 正常完成 ✅ 排队后完成 Run 1 → Run 2(串行) 每条消息都重要,有顺序依赖
Interrupt ⏸ 中断(保留已完成节点) ✅ 立即开始 Run 1 部分 + Run 2 用户想改变方向,但不需要撤销已完成的工作
Rollback 🗑 删除(彻底撤销) ✅ 从干净状态开始 仅 Run 2 "我改主意了,之前说的不算" — 需要干净重来
实际产品中的推荐选择

通用聊天应用:Interrupt 或 Rollback(用户体验更流畅)。任务管理/工作流:Enqueue(每条指令都需要执行)。金融/表单:Reject(防止重复提交)。大多数对话助手场景下,Rollback 最符合用户直觉——"我已经改主意了,上一条当没说"。