用户已经扫码登录,Agent 却又打开登录页。
用户点了停止,几十秒前的回调却让任务继续。
浏览器工具返回超时,Agent 再点一次“提交”,结果发出了两条内容。
这三种故障可能发生在同一条链路,但修法不同:要分别处理页面事实、任务控制权和业务动作结果。
2026 年 10 月 29 日,LangChain 官方发布 Managed Deep Agents v0.10,介绍浏览器操作、人工交接和异步子任务。
这篇教程借这个近期更新,回答一个更贴近落地的问题:遇到登录、扫码或人工验证时,怎样让 Agent 停得住、接得回、不会被旧回调带偏?
你会得到三张图、一份可直接运行的 Python+SQLite 实验,以及一套从故障现象追到状态条件的检查方法。
阅读边界:产品能力依据官方文档;后文状态机是本文的工程设计建议,不是对 Managed Deep Agents 内部实现的披露。代码仅使用标准库,没有连接真实网站、模型或托管服务,也不会操作你的账号。
图 1:登录状态、控制权交回、业务许可是三个独立条件。任何一个缺失,都不能简单理解为“继续点原来的按钮”。
一、先看热点:Browser Agent 正在补上哪块能力?
LangChain 的10 月 29 日版本说明介绍:浏览器操作遇到需要用户登录的步骤时,可以交给用户处理,交回后再检查页面并继续;异步子任务则能在后台运行,避免主对话一直阻塞。
另一篇官方浏览器技术说明区分了两种会话范围:默认 thread 范围隔离不同对话;user 范围可在同一已认证用户的不同对话间共享 Cookie 等浏览器状态,各对话保留自己的窗口。
**共享登录状态,并不意味着共享业务授权,也不意味着任意任务可以操作任意窗口。**这是从产品能力落到系统设计时需要额外补上的边界。
如果你使用开源 Deep Agents,自行接入浏览器与交接机制,不能把托管产品的开箱能力自动当作开源部署已经具备的能力。
本文不提供未经运行的云端 SDK 配置,也不复述官方性能数字;我们把可验证范围收窄到“交接和恢复的控制逻辑”。
二、先把三个问题拆开,排障会快很多
1.页面状态:目标网站现在真的登录了吗?
“我登录好了”是人发出的通知,不能直接替代页面检查。
用户可能登录了另一个标签页、另一个账号,或者认证页成功但目标业务页仍未刷新。
恢复时应获取当前受控页面的新快照,确认目标网站、账号上下文和业务页面状态;不要复用交接前缓存的元素引用或坐标。
例如,交接前的“提交”按钮可能已被弹窗遮挡,或者列表刷新后同一位置变成“删除”。这时失败点不是模型不够聪明,而是观察已经过期。
2.控制状态:这次交回属于哪一轮任务?
假设同一任务先后请求两次人工交接。
第一轮用户打开的旧页面晚了几十秒才回传“完成”,此时系统正在等待第二轮交接。
如果只检查 state == WAIT_USER,旧回调可能恰好命中新一轮等待,错误地唤醒任务。
因此每次状态变化都增加一个 revision。恢复请求必须携带它所对应的等待版本,更新时同时匹配任务、状态和版本。
任务 ID 回答“哪件事”,revision 回答“这件事的哪一轮”。
revision 不是密码,不提供身份认证;知道版本号的人也不能因此获得恢复权限。
3.业务状态:登录之后,允许做什么?
登录只证明网站接受了某个会话。
允许查看列表、发布文章、删除记录、付款,分别是不同的业务权限与操作范围。
服务端需要核对发起者、租户、目标账号和允许动作;不能让模型传入 allowed=True 就成为审批依据。
对于需要人工确认的动作,认证完成也不能自动消耗另一项尚未获得的批准。
三、设计一条能拒绝旧回调的恢复路径
图 2:本文的教学状态机。取消、过期和结果未知分别有独立去向;UNKNOWN 不存在自动重发边。
先定义七种状态:
| 状态 | 意义 | 接下来核对什么 |
|---|---|---|
| READY | 当前允许准备下一步 | 是否仍处在允许范围、页面是否新鲜 |
| WAIT_USER | 已交接,等用户操作 | 身份、本轮 revision、截止时间、页面事实 |
| EXPIRED | 等待已过期 | 需要重新建立交接上下文,不能复用旧回调 |
| CANCELLED | 用户取消 | 保持停止,旧结果不得唤醒 |
| IN_FLIGHT | 外部动作已开始派发 | 等待明确结果,记录关联信息 |
| UNKNOWN | 动作结果未确认 | 查询目标结果或人工核对 |
| DONE | 结果已确认 | 保留回执与证据 |
工程上,恢复操作最好采用数据库条件更新,而不是“先读状态,再无条件写状态”:
UPDATE runs
SET state = 'READY', revision = revision + 1
WHERE id = :run_id
AND state = 'WAIT_USER'
AND revision = :expected_revision
AND expires > :server_now;
关键在于检查受影响行数。
更新了 1 行,才说明本次请求赢得这个状态转换;更新 0 行,则可能是旧版本、已取消、已超时或另一请求已经处理,必须重新读取状态解释原因。
这能防止顺序到达的重复恢复再次命中同一轮等待;它也为并发竞争提供必要的条件,但不能取代数据库锁策略、事务隔离、超时重试及执行器控制权管理。
**状态行更新成功,也不意味着旧执行器已经停手。**多进程生产系统还需要浏览器控制权租约或执行代次校验,让过期执行器不能继续点击。下面的实验没有实现这部分。
四、运行一次:旧回调为什么会被拦下来?
环境为 Python 3.12,使用解释器内置的 SQLite,不需要安装依赖或申请 Key。
将下面完整代码保存为 handoff_demo.py,执行:
python handoff_demo.py
所有时间都是人为提供的教学时刻,不是当前真实时间;示例中的 60 秒是实验参数,与托管产品的链接有效期无关。
身份、页面检查和权限用三个布尔值模拟,便于单独验证状态转换。生产系统必须从可信服务端认证、业务授权和新页面观察生成这些结果,不能直接接受客户端或模型声明。
"""Python 3.12 / SQLite 教学实验;不包含真实认证或浏览器控制。"""
import sqlite3
import tempfile
from pathlib import Path
def connect(path):
db = sqlite3.connect(path)
db.execute("""CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY, state TEXT, revision INTEGER, expires INTEGER)""")
db.commit()
return db
def create(db, run):
with db:
db.execute("INSERT INTO runs VALUES (?, 'READY', 0, 0)", (run,))
def state(db, run):
return db.execute(
"SELECT state, revision, expires FROM runs WHERE id=?", (run,)
).fetchone()
def move(db, run, old, revision, new, expires=0, now=None):
# state + revision 都匹配才允许写入;超时判断同样放进更新条件。
sql = """UPDATE runs SET state=?, revision=revision+1, expires=?
WHERE id=? AND state=? AND revision=?"""
args = [new, expires, run, old, revision]
if now is not None:
sql += " AND expires>?"
args.append(now)
with db:
return db.execute(sql, args).rowcount == 1
def handoff(db, run, revision, now):
return move(db, run, "READY", revision, "WAIT_USER", now + 60)
def resume(db, run, revision, now, *, owner_ok, page_ok, allowed):
# 三项由受信服务端提供;教学中用布尔值替代真正的鉴权与新页面检查。
if not (owner_ok and page_ok and allowed):
return False
return move(db, run, "WAIT_USER", revision, "READY", now=now)
def demo():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "runs.db"
db = connect(path)
create(db, "a")
assert handoff(db, "a", 0, 100)
db.close()
db = connect(path) # 真正关闭连接、重新打开同一文件。
assert state(db, "a") == ("WAIT_USER", 1, 160)
print("PASS persisted_wait: WAIT_USER rev=1")
for label, flags in [
("wrong_owner", (False, True, True)),
("page_unverified", (True, False, True)),
("no_permission", (True, True, False)),
]:
assert not resume(db, "a", 1, 110, owner_ok=flags[0],
page_ok=flags[1], allowed=flags[2])
assert state(db, "a")[0] == "WAIT_USER"
print(f"PASS {label}: blocked")
assert resume(db, "a", 1, 110, owner_ok=True,
page_ok=True, allowed=True)
# 第二轮交接已经开启,第一轮的迟到回调不能恢复它。
assert handoff(db, "a", 2, 120)
assert not resume(db, "a", 1, 125, owner_ok=True,
page_ok=True, allowed=True)
assert state(db, "a") == ("WAIT_USER", 3, 180)
print("PASS stale_callback: rev=1 cannot resume rev=3")
assert not resume(db, "a", 3, 180, owner_ok=True,
page_ok=True, allowed=True)
assert move(db, "a", "WAIT_USER", 3, "EXPIRED")
print("PASS expiry: at deadline blocked; EXPIRED")
create(db, "b")
assert handoff(db, "b", 0, 100)
assert move(db, "b", "WAIT_USER", 1, "CANCELLED")
assert not resume(db, "b", 1, 110, owner_ok=True,
page_ok=True, allowed=True)
print("PASS cancelled: callback cannot revive")
create(db, "c")
assert move(db, "c", "READY", 0, "IN_FLIGHT")
assert move(db, "c", "IN_FLIGHT", 1, "UNKNOWN")
assert not move(db, "c", "READY", 2, "IN_FLIGHT")
assert move(db, "c", "UNKNOWN", 2, "DONE")
print("PASS unknown: no automatic replay; reconciled DONE")
db.close()
if __name__ == "__main__":
demo()
本次实际运行输出:
PASS persisted_wait: WAIT_USER rev=1
PASS wrong_owner: blocked
PASS page_unverified: blocked
PASS no_permission: blocked
PASS stale_callback: rev=1 cannot resume rev=3
PASS expiry: at deadline blocked; EXPIRED
PASS cancelled: callback cannot revive
PASS unknown: no automatic replay; reconciled DONE
图 3:本地八项边界检查的结果,属于控制逻辑实验,不是网站兼容性、认证安全性或托管服务性能评测。
读懂最容易漏掉的一段
第一次交接后,任务是 WAIT_USER, revision=1。
正常恢复使它变成 READY, revision=2;第二次交接再变成 WAIT_USER, revision=3。
第一轮迟到回调仍携带 revision=1。虽然它看到的状态名字又是 WAIT_USER,SQL 的版本条件仍然拒绝它。
如果拿掉 revision 条件,只凭状态名,系统就失去了区分两轮交接的能力。这就是“明明已经暂停,旧消息却让任务又动起来”的一类可复现原因,不代表所有此类故障都由同一根因造成。
持久化检查实际证明了什么?
代码确实关闭 SQLite 连接,再打开同一文件,观察到等待状态、版本和截止时间仍然存在。
这证明本实验中的等待记录不只保存在 Python 内存里;没有测试整个工作进程崩溃、断电、数据库损坏、云端故障转移或真实浏览器登录会话恢复。
图状态持久化与浏览器 Cookie 生命周期是不同问题,不应拿前者的成功证明后者。
五、最危险的误判:超时就再点一次
浏览器动作超时,至少可能意味着两件不同的事:
- 命令尚未派发,页面没有发生动作。
- 命令已经派发,但返回值、截图或网络回执没有及时到达。
第二种情况下,直接重复点击可能重复发帖、重复提交表单或重复生成工单。
示例把这种情况记为 UNKNOWN,并验证它不能走“从 READY 开始派发”的路径。之后 UNKNOWN → DONE 只是模拟“已通过独立证据确认结果”,没有真实查询网站,也没有提供 exactly-once 保证。
生产核对应依据目标系统的稳定对象 ID、业务操作键、状态记录或人工确认。只看一个成功提示文本,可能不足以区分刚刚的动作和历史动作。
如果结果查不到,保持待核对,不应把“没查到”直接推导为“从未执行”。需要重新执行时,应由明确的业务规则和新授权流程决定,不能让一个通用重试装饰器自动代办。
有关执行端去重,可配合社区已有的LangGraph 恢复后重复建单实战阅读;本文的重点是交接控制,执行端幂等仍是另一层工作。
六、出现故障时,按这张表找证据
| 现象 | 先看哪条证据 | 应对方向 |
|---|---|---|
| 用户说登录成功,仍显示登录页 | 当前页面、新快照、目标账号与会话范围 | 核对正确窗口和账号,不索取聊天中的密码 |
| 第一轮完成消息恢复了第二轮任务 | run_id、等待 revision、回调 revision | 恢复时做状态+版本条件更新 |
| 点了停止,任务又继续 | 取消记录、旧回调、执行器控制权 | 终态拒绝旧回调,同时阻止旧执行器派发 |
| 不同用户看到了同一网站账号 | 服务端身份映射、浏览器状态归属 | 检查按用户隔离及租户归属,不能按模型自报 user_id |
| 提交超时后出现重复内容 | 派发记录、目标对象与业务回执 | 进入 UNKNOWN 核对,避免自动重发 |
| 登录成功后执行了未允许动作 | 业务权限与确认记录 | 分开认证与动作许可,在服务端校验 |
日志建议只记录事件关联信息,例如 run_id、revision、state、时间、动作类别、结果状态;不要记录密码、验证码、Cookie、完整登录页面表单或可直接接管会话的交接链接。
七、接入 LangGraph 或自建 Agent 时,放在哪一层?
建议拆成三个职责,别让模型的自然语言承担全部控制:
- 对话层解释为何需要用户介入,接收用户已完成的通知,继续不依赖登录的工作。
- 控制层持久化等待状态,校验发起者与版本,处理截止时间、取消和执行器控制权。
- 执行层重新观察当前页面,仅在允许范围内执行动作,遇到未知结果先返回证据与待核对状态。
使用 LangGraph 的暂停恢复机制时,仍应单独验证业务交接与浏览器控制边界;不要把一段 resume 输入当作用户身份或执行许可。
若需要跨进程恢复图状态,可以参考thread_id 与 SQLite 持久化教程;本篇标准库状态机没有调用 LangGraph,不声称替代其 checkpoint 或 interrupt。
这个拆分的价值是能定位责任:页面错了查执行层,回调串轮次查控制层,提示不清楚查对话层,而不是所有故障最后都改一次 system prompt。
你可以今天就做的一次验收
先在测试网站或无副作用的流程中,故意保留第一轮交接回调,再开启第二轮等待,最后投递旧回调。
预期结果不是“模型看懂了旧消息”,而是服务端明确拒绝状态转换,当前页面也没有被旧执行器操作。
再分别验证取消、到期、错误身份、无权限和结果未知。通过这些边界测试后,再扩大真实业务范围。
如果你正在接入浏览器 Agent,可以在评论里提供:部署方式、会话范围、故障发生在交接前还是交接后,以及脱敏的状态转换记录。不要贴账号密码、验证码、Cookie 或交接链接。
资料与实测说明:官方动态核对日期为 2026-11-02;引用的两篇 LangChain 原文均标注发布于 2026-10-29。三张配图为社区原创工程图解;Python 3.12+SQLite 教学实验已运行,八项检查通过。未运行 Managed Deep Agents 云服务,未测试真实浏览器认证、并发工作进程、租约实现、真实网站发布或外部服务幂等。本文章由 AI 辅助研究、编写与制图,内容与实验结果逐项核验。


