browser-use 的 Agent 循环拆解:一步里究竟发生了什么

2026-07-30

本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。

browser-use 每一步发给模型的不是一段越来越长的对话,而是一条被整体重写的状态消息。 你在 browser_use/agent/message_manager/views.py 里能看到,承载全部输入的 MessageHistory 只有三个字段:system_messagestate_messagecontext_messages。系统提示词在初始化时写进去一次,状态消息每一步替换掉旧的那条,临时消息每一步开头清空。整个循环里,MessageManager 从来不往历史里追加 AssistantMessage——模型上一步的判断和动作结果,是以纯文本形式被重新拼回状态消息的 <agent_history> 段落里的。理解这一条,后面所有关于裁剪、失败、重试的设计才讲得通。

站内已有几篇相邻内容分工不同:终端编码 Agent 的循环拆解见从源码看 pi 的 agent loop,上下文管理的通用做法见Agent 上下文管理,什么条件下该把循环强行停下来见无限循环的停机条件;这篇只盯 browser-use 自己的一步。

一、一步里发生了什么:step() 的四个阶段

Agent.step()browser_use/agent/service.py 里,主体是一个 try / except / finally,注释直接把阶段编号写在代码里:

			# Phase 1: Prepare context and timing
			browser_state_summary = await self._prepare_context(step_info)

			# Clear previous step state after context preparation (which needs
			# them for the "previous action result" prompt) but before the LLM
			# call, so a timeout during _get_next_action or _execute_actions
			# won't leave stale data from the previous step.
			self.state.last_model_output = None
			self.state.last_result = None

			# Phase 2: Get model output and execute actions
			await self._get_next_action(browser_state_summary)
			await self._execute_actions()

			# Phase 3: Post-processing
			await self._post_process()

阶段 1 之前还有一段被注释成 Phase 0 的逻辑:调用 wait_if_captcha_solving(),如果确实等过,就重置 step_start_time(把等待时间从这一步耗时里剔掉),并把等待结果包成一条 ActionResult(long_term_memory=msg) 塞进 state.last_result,让模型下一步能看到「等了多久、结果是 success 还是 failed 还是 timeout」。

_prepare_context() 做的事比名字听着多。它先 get_browser_state_summary(include_screenshot=True) 抓一份浏览器状态——注意截图是无条件抓的,代码注释解释了原因:即使 use_vision=False 也要抓,方便云端同步;是否把这张图放进提示词,是后面 create_state_messages 里另一套判断。接着检查新下载文件、按当前 URL 更新可用动作模型、从注册表取出这个页面适用的动作描述,再调 prepare_step_state() 把上一步的模型输出和结果写成一条历史项,尝试压缩历史,最后 create_state_messages() 把整条状态消息拼出来。

拼完还没结束。_prepare_context() 尾部是一串「注入」调用,每一个都可能往 context_messages 里加一条临时的 UserMessage_inject_budget_warning(步数用掉 75% 以上时提醒先保存部分结果再 done)、_inject_replan_nudge_inject_exploration_nudge_inject_loop_detection_nudge,以及两个更强硬的 _force_done_after_last_step_force_done_after_failure——后两个不只加消息,还会把 self.AgentOutput 直接换成 self.DoneAgentOutput,也就是从 schema 层面只留下 done 一个动作可选。

阶段 2 的 _get_next_action()asyncio.wait_for 包住模型调用,超时时间来自 llm_timeout(构造函数里按模型名判断,未显式传入时给不同模型不同值)。拿到输出后立刻检查一次暂停/停止标记,再做回调和会话保存。阶段 3 的 _post_process() 负责下载检查、计划状态更新、把动作记进循环检测器,以及维护连续失败计数。

finally 里的 _finalize() 才是「这一步落账」的地方:写 StepMetadata、存截图、生成 AgentHistory 条目、派发 CreateAgentStepEvent,最后 self.state.n_steps += 1

二、消息不是聊天记录:三个槽位与每步重建

MessageHistory.get_messages() 的实现只有几行,顺序写得很直白:

	def get_messages(self) -> list[BaseMessage]:
		"""Get all messages in the correct order: system -> state -> contextual"""
		messages = []
		if self.system_message:
			messages.append(self.system_message)
		if self.state_message:
			messages.append(self.state_message)
		messages.extend(self.context_messages)

		return messages

所以任意一步,发给模型的消息条数是 1 条系统提示词 + 1 条状态消息 + 若干条本步临时消息。状态消息由 browser_use/agent/prompts.py 里的 AgentMessagePrompt.get_user_message() 组装,段落顺序是 <user_request><agent_history><agent_state><browser_state><read_state><page_specific_actions>,最后压一个 <step_info>。为什么把步数和日期放在最后,代码注释给了理由:这两项每步都变,放在尾部之后,前面的内容原则上可以当成可缓存前缀;系统消息和状态消息都带 cache=True

<agent_state> 段里是文件系统描述和 todo.md 内容,启用规划时还会插一段 <plan>,把计划项按 [x] / [>] / [ ] / [-] 四种标记渲染出来。<browser_state> 段前面有一个 <page_stats>,统计链接数、可交互元素数、iframe 数、shadow DOM 开闭数量;元素总数不足 10 个时会直接在里面写一句「页面看起来是空的,考虑等待」,有网络请求在飞且文本密度过低时提示可能还在加载。这些都不是模型自己推的,是 _extract_page_statistics() 遍历简化 DOM 树数出来的。

系统提示词不是一份,而是按运行模式挑模板。SystemPrompt._load_prompt_template() 里的分支覆盖了 browser-use 自家微调模型、flash 模式、是否带 thinking 等组合,browser_use/agent/system_prompts/ 下一共 8 份提示词文件。模板本身也把输入结构原样告诉了模型,system_prompt.md 开头就在列举「每一步你的输入包含 <user_request><agent_history><agent_state><browser_state><browser_vision><read_state>」,并说明带星号 *[ 前缀的索引是自上一步以来新出现的可交互元素。

把这一层的组成部分对齐到仓库位置,方便你按需要跳读:

组成部分它负责什么对应仓库位置你什么时候会碰到它
Agent.step() / _prepare_context()一步的阶段编排、注入各种提醒、强制收尾browser_use/agent/service.py想知道某条提醒是谁加进去的
MessageManager维护历史项、压缩、敏感数据过滤、组装状态消息browser_use/agent/message_manager/service.py提示词太长、内容莫名消失时
MessageHistory / HistoryItem三个消息槽位与单步历史项的文本形态browser_use/agent/message_manager/views.py想搞清模型到底看到几条消息
AgentMessagePrompt把浏览器状态渲染成状态消息的各个段落browser_use/agent/prompts.py要改喂给模型的页面表示
系统提示词模板(8 份)按模式区分的行为规则与输入格式说明browser_use/agent/system_prompts/调整全局行为约束
multi_act()顺序执行本步动作并做页面变化守卫browser_use/agent/service.py一步多动作没跑完时
ActionLoopDetector动作重复与页面停滞的指纹统计browser_use/agent/views.py想知道循环提醒的触发条件
provider 适配层(15 个目录)各家模型服务的调用与解析browser_use/llm/换模型、排解析报错
watchdog(14 个)浏览器侧的崩溃、弹窗、下载、安全等监听browser_use/browser/watchdogs/浏览器层行为异常时

三、上下文怎么被裁剪:几道独立的闸门

历史文本由 agent_history_description 这个属性现算出来,裁剪逻辑分几层,互不重叠。

第一道是历史条数上限。 max_history_itemsNone 时全量拼接;设了值且条数超限时,保留第一条(初始化那条)、插一行 <sys>[... N previous steps omitted...]</sys>、再接最近的 max_history_items - 1 条。注意 MessageManager.__init__ 里有一句断言:这个值必须是 None 或大于 5,写 3 会在构造时就崩。

第二道是压缩摘要。 maybe_compact_messages() 有两道闸门且都必须过:步数间隔(compact_every_n_steps 默认 25)和字符下限(trigger_char_count 未设时解析为 40000)。两者都满足才会调一次模型,把 <agent_history> 全文总结成一段纯文本存进 compacted_memory,然后只留第一条历史项加最近 keep_last_items(默认 6)条。这段摘要拼进提示词时外面套了一层带注释的标签,措辞很克制:把它当作未经核实的上下文,除非你在本次会话里自己确认过,否则不要在 done 里报告为已完成。压缩用的系统提示词里也重复了同一条纪律——只有看到明确的成功确认才标记完成,其余标 IN-PROGRESS,绝不从上下文推断完成。

第三道是一次性内容。 动作返回结果里带 include_extracted_content_only_once 标记的抽取内容,会被写进 read_state_description,包在 <read_state_0><read_state_1> 这类标签里,而这个字段在每次 _update_agent_history_description() 开头就被清空。也就是说抽取到的长文本只在紧接着的那一步可见,下一步就没了。带 long_term_memory 的结果才会常驻历史;两个字段都有 60000 字符的硬上限,超出直接截断并追加一行说明。错误文本另有一套:超过 200 字符时只留前 100 和后 100,中间用省略号连接。

第四道在页面表示和 URL 上。 DOM 文本超过 max_clickable_elements_length(默认 40000)直接截断,并在标题里注明截断长度。长 URL 会在发送前被替换成短形式:_replace_urls_in_text() 只处理 query 和 fragment 部分,超过 _url_shortening_limit(默认 25)就截断并接上该部分 md5 的前 7 位,且只在真的更短时才替换。模型返回后,_recursive_process_all_strings_inside_pydantic_model() 会递归遍历输出对象里所有字符串、字典、列表、嵌套模型,把短形式换回原始 URL——这是为了让模型不必看到超长参数串,同时保证执行时用的是真地址。

四、失败的一步怎么进入下一轮

失败在这套循环里不是分支,而是数据。它最终都会变成一条 ActionResult(error=...),然后以文本形式出现在下一步的 <agent_history> 里。

最内层是 multi_act()。它顺序执行本步的动作列表,单个动作抛异常时,InterruptedError 和连接类错误会往上重抛,其余异常被就地转成 ActionResult(error=...) 追加进结果并直接 return——注释写明了意图:保留部分结果,让 Agent 知道失败之前哪些动作成功了。这一层还有两道页面变化守卫,函数 docstring 自己列了出来:

		Two layers of protection prevent executing actions against stale DOM:
		  1. Static flag: actions tagged with terminates_sequence=True (navigate, search, go_back, switch)
		     automatically abort remaining queued actions.
		  2. Runtime detection: after every action, the current URL and focused target are compared
		     to pre-action values. Any change aborts the remaining queue.

另外 done 只允许作为单独动作出现,出现在第二个及之后的位置会被中止;动作之间还会按浏览器配置里的 wait_between_actions 停一下。

外一层是 step()except,统一交给 _handle_step_error()InterruptedError 被当成正常的用户打断,只记一条 warning 就返回。连接类错误会先看浏览器会话是不是正在重连,是就等一段时间,重连成功则把这一步记成「连接丢失并已恢复」;确认浏览器真的关了才置 stopped。其余异常一律格式化成错误文本,写进 state.last_result,并把 consecutive_failures 加一。

计数规则有个容易看漏的细节,在 _post_process() 里:只有单动作步的错误才计入连续失败,多动作步里的错误不计,交给循环检测和重规划提醒去处理。这个取舍很实际——一步塞五个动作,其中一个失败很常见,如果都算连败,几步就把预算耗光了。

阈值一路往上叠:连续失败达到 planning_replan_on_stall(默认 3)时注入一条重规划建议,让模型输出新的 plan_update;达到 max_failures(默认 5)且开启了失败后收尾时,_force_done_after_failure() 把可用工具压缩成只有 done,并明确要求任务没完成就把 donesuccess 设为 false;run() 的主循环则在连续失败数达到 max_failures 加上那一次收尾机会时 break。走到 max_steps 最后一步是同样的处理路径。

模型不给动作也算一种失败。_get_model_output_with_retry() 发现 action 为空或全是空对象时,会追加一条说明性的用户消息重问一次;再空就自己造一个 done 动作,success 为 false,文本是 No next action returned by LLM!——不让循环卡死,但把这次异常留在历史里。

「原地打转」是另一类失败,由 ActionLoopDetector 单独统计:动作按名称加参数归一化后算哈希,进一个滑动窗口(loop_detection_window 默认 20),waitdonego_back 被显式排除在统计外;重复次数到 5、8、12 分级升级提醒措辞,页面指纹(URL、DOM 文本、元素数)连续 5 次不变时再加一条。归一化这一步值得单独留意:搜索动作会把关键词拆成词元排序去重再拼,所以调换词序、加个标点重搜一遍算同一个动作;点击只按元素索引算,输入按索引加归一化后的文本算;导航按完整 URL 算,注释里明确了理由——换路径是正常探索,只有反复导航到完全相同的 URL 才是循环信号;其余动作按名称加排序后的参数算。这解释了为什么有时候你觉得自己在换招,提醒还是来了。顺带说一句,这里的注释和代码有过不一致(函数 docstring 说导航只按域名算,实现是按完整 URL),读这类高频迭代的仓库时,结论要以代码为准。这些都只是注入提醒,不会强制终止——真要停下来靠的是失败计数和步数预算。失败该怎么分类、重试怎么设计,可以对着失败重试的工程做法一起看。

五、和终端里的编码 Agent 差在哪,以及这套设计放弃了什么

差别不在「一个开浏览器一个开终端」,而在几个能在代码里查到依据的取向。

观测对象每步失效。 终端编码 Agent 的工具输出通常留在对话里,几轮之后还能引用。这里的可交互元素索引来自当步的 DOM 快照,*[ 前缀标记的是自上一步以来新出现的元素(提示词里限定了「URL 未变」这个前提),索引本身每步重编号。跨步复用一个索引在设计上就不成立。

历史是被重写的文本,不是消息序列。 前面说过 MessageManager 不追加 AssistantMessage。好处是提示词长度可控、前缀可缓存;代价是模型没有逐字的自我回放,压缩之后连历史都变成一段被明确标注为未核实的摘要。

批量执行是被主动掐断的。 max_actions_per_step 默认 5,超出直接截断;再叠上 terminates_sequence 静态标记和运行时 URL/焦点比对。一步多动作在这里更像乐观优化,不是可依赖的编排能力。

收尾会被 schema 强制。 最后一步和连败上限之后,输出模型被换成只含 done,模型无从选择其他动作。

放弃的部分同样要写清楚。它不管目标站点的使用条款——你让它去哪它就去哪,是否违反对方的 robots、服务协议、自动化访问限制,全在使用者身上。它不保证动作幂等:一步失败重来可能造成重复提交,代码里对这类语义没有任何回滚概念,只是把错误写进历史。验证码相关的处理由浏览器侧完成并把结果通知模型,系统提示词明确要求模型不要自己去解;判定被验证码挡住时也只是日志里给一条提示。这些都不是绕过风控的手段,遇到明确不欢迎自动化的站点,正确做法是停下来换官方 API 或申请授权。

敏感数据这一块的边界更需要注意。sensitive_data 的机制是占位符:提示词里只给占位符名字,要求模型用 <secret>占位符名</secret> 的形式填写,真值在执行时替换;回灌进状态消息时再做一次脱敏过滤。但如果没配 allowed_domains,构造函数只打印一条告警,说明一旦访问到恶意页面遇上提示注入,敏感数据可能被带出去——它警告,不阻止。同时别忘了截图和 DOM 文本都会进模型:页面上出现的姓名、手机号、订单、余额,会随状态消息一起发给模型服务商。各家的数据处理规则不同且会调整,以官方最新说明为准。

由此可以划出不适用的场景:带真实登录态操作重要账号、涉及资金或不可逆提交的流程、有严格审计留痕要求的系统、以及明确禁止自动化访问的站点。这类场景要么别用,要么至少把权限收到最小、把域名白名单锁死,思路可以参考最小权限设计。顺带一句,判定成功与否也别只看模型自报:启用 judge 时它的裁定会挂在结果上,但代码注释写明不覆盖 last_result.success,两个值都保留下来供比较。

六、上手与避坑清单

max_history_items 设成小数字。 会踩是因为直觉上想省 token 就往小调。MessageManager 构造时有断言,必须是 None 或大于 5,写 3 会在初始化阶段直接抛出来。要控长度,先动压缩设置和 DOM 截断长度。

以为压缩会在上下文变长时自动发生。 会踩是因为只看到「有压缩」就默认它随时生效。实际是步数间隔和字符下限两道闸门都得过,默认要跨过 25 步才有一次机会。任务只跑十几步的话,压缩根本不会触发;想早点压,就得显式调低步数间隔或字符阈值。

把抽取到的长文本当成模型的长期记忆。 会踩是因为上一步明明看到内容进了提示词。带一次性标记的抽取内容只在紧接着那一步出现,之后被清空。需要跨步引用就落到文件系统里,或写进 todo.md,靠 <agent_state> 每步重新带出来。

一步塞多个动作并期望全部执行。 会踩是因为日志里确实一次打印了多个动作。实际上导航类动作带 terminates_sequence 标记会中止后续队列,运行时只要 URL 或焦点目标变了也中止,done 还必须单独出现。跨页面的操作按步拆开,别指望一步走完。

use_vision=False 来省截图开销。 会踩是因为把「不看图」等同于「不截图」。_prepare_context() 里截图是无条件抓的,use_vision 只决定要不要放进消息;另外部分模型会被代码强制关掉 vision,构造时会打 warning。真想减开销,除了关 vision,还可以看 llm_screenshot_size 这类缩图配置。

配了 sensitive_data 却没锁域名。 会踩是因为不报错也能跑。没有 allowed_domains 时只有一条告警,提示注入场景下凭据可能外泄。把白名单配上,域名维度的凭据也要保证被白名单覆盖,否则同样只是告警。

done 文本里核对 URL 时对不上。 会踩是因为长 URL 在发送前被截断加了 md5 短哈希,回填靠字符串替换。涉及精确 URL 的任务,最终结果要自己复核一遍原始地址,别直接把模型抄回来的串当真。

一份可以拿去自查的清单:这一步的提示词里到底有几条消息、状态消息哪一段最长、压缩是否已经发生过(compaction_count 会累加)、连续失败计数当前是多少、循环检测器的重复次数和停滞计数是多少、allowed_domains 是否锁死。这六个问题答得出来,线上出问题时你基本知道该看哪里。

再往下读的话,browser_use/agent/service.py 里的 multi_act() 会把动作交给 Tools.act(),顺着这个 import 进 browser_use/tools/service.py 就能看到每个动作到底做了什么;想搞清页面表示为什么是那个样子,回到 browser_use/agent/prompts.py_get_browser_state_description();想知道浏览器层的异常是谁兜住的,看 browser_use/browser/watchdogs/ 下那 14 个 watchdog。仓库里还有 124 个示例文件可以对照着跑,项目采用 MIT 许可证。读代码的顺序建议还是从这一步的四个阶段出发——先看清一步,再看清一轮。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 源码梳理browser-use 为什么要备 8 份系统提示词而不是 1 份

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。