浏览器 Agent 卡在登录页?Deep Agents 新动向与 Python 实战:安全交接、恢复与迟到回调排障

用户已经扫码登录,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 时,放在哪一层?

建议拆成三个职责,别让模型的自然语言承担全部控制:

  1. 对话层解释为何需要用户介入,接收用户已完成的通知,继续不依赖登录的工作。
  2. 控制层持久化等待状态,校验发起者与版本,处理截止时间、取消和执行器控制权。
  3. 执行层重新观察当前页面,仅在允许范围内执行动作,遇到未知结果先返回证据与待核对状态。

使用 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 辅助研究、编写与制图,内容与实验结果逐项核验。