跳到主要内容

数据截至 (上游 commit ae57a2357745)

第 4 章 · 自主浏览器:不用眼睛怎么上网

本章讲:BrowserAgent 怎么用一台真实 Chrome 完成「搜索 → 逐页阅读 → 记笔记 → 填表 → 汇总」,全程只靠文本,不用视觉模型。


4.1 它要解决的小问题

让 AI 上网找答案,难在三处:

难点具体是什么
页面太大一个网页的 HTML 动辄几十万字符,塞不进上下文
会被拦自动化浏览器有一堆指纹特征,很多站直接拒绝
何时收工模型容易在链接里无限游走,或看了一页就妄下结论

AgenticSeek 三样都是纯文本方案——没有截图、没有视觉模型、没有可访问性树


4.2 顶层流程

怎么读这张图:主体是一个循环,三个出口(找到答案 / 链接耗尽 / 用户叫停)。

用户请求
|
v
① 让 LLM 把请求改写成搜索词 search_prompt()
| (模型觉得这问题不该搜网 → 直接 REQUEST_EXIT)
v
② SearxNG 搜索,取前 16 条 searxSearch.execute()
|
v
┌─────────── 导航循环(未访问链接非空且未停)──────────┐
│ │
│ ③ 问 LLM:读完这页,记什么笔记?下一步去哪? │
│ | │
│ ├─ 答案里有 [字段](值) → 填表并提交 │
│ ├─ 答案里有 URL → go_to() 并抓页面文本 │
│ ├─ 答案里有 GO_BACK → 回搜索结果重选 │
│ └─ 答案里有 REQUEST_EXIT → 跳出循环 │
│ │
└──────────────────────────────────────────────────────┘
|
v
④ 把所有笔记喂给 LLM,让它写成结论 conclude_prompt()

源码在 sources/agents/browser_agent.py:347-445BrowserAgent.process)。


4.3 第一步:把请求变成搜索词

不直接拿用户原话去搜,先让 LLM 改写(search_promptsources/agents/browser_agent.py:274-292)。prompt 里塞了当天日期和三组示例:

用户说期望模型写出
“go to twitter, login with username toto…”search: Twitter login page
“I need info on the best laptops for AI this year.”search: best laptops 2025 to run Machine Learning model, reviews
“Search for recent news about space missions.”search: Recent space missions news, <今天日期>

注意第一条:用户想登录 Twitter,模型该搜的是「Twitter 登录页」而不是用户名密码。这是在教模型区分「目标」和「查询」。

日期是运行时注入的(get_today_date()sources/agents/browser_agent.py:46-49),解决本地模型知识截止日期过旧、总搜出陈年结果的问题。

如果模型判断这问题根本不需要上网,就回 REQUEST_EXITprocess 直接返回(sources/agents/browser_agent.py:363-365)。


4.4 搜索:自建 SearxNG

搜索走的是 docker-compose 里自托管的 SearxNG(元搜索引擎,把多个搜索引擎的结果聚合起来),POST 表单请求后用 BeautifulSoup 扒 HTML(sources/tools/searxSearch.py:62-107):

for article in soup.find_all('article', class_='result'):
url_header = article.find('a', class_='url_header')
...
results.append(f"Title:{title}\nSnippet:{description}\nLink:{url}")

返回的是一整块纯文本,再由 jsonify_search_results()\n\n 切块、按 Title:/Snippet:/Link: 前缀解析回结构(sources/agents/browser_agent.py:198-215),最后 [:16] 截断。

为什么绕这么一圈:工具的返回值统一是字符串(Tools.execute 的契约),所以要么在工具里 JSON 化,要么在调用方解析。项目选了后者。

注意 requests.post(..., verify=False)sources/tools/searxSearch.py:91)——对本地 SearxNG 关掉了证书校验。


4.5 页面 → Markdown:最关键的一步

Browser.get_text() 是整章的核心(sources/browser.py:390-418)。四道工序:

driver.page_source (几十万字符 HTML)
|
① BeautifulSoup 剥掉 script / style / noscript / meta / link
v
② markdownify 转 Markdown(ATX 标题、strip 掉 a 标签、autolinks=False)
v
③ 逐行过 is_sentence() 判定,不像人话的行整行丢弃
v
④ 图片 ![alt](url) 换成 [IMAGE: alt],包上 [Start of page]/[End of page],硬截 32768 字符

第 ③ 步是最有意思的启发式(sources/browser.py:378-388):

def is_sentence(self, text: str) -> bool:
text = text.strip()
if any(c.isdigit() for c in text):
return True # 含数字直接放行
words = re.findall(r'\w+', text, re.UNICODE)
word_count = len(words)
has_punctuation = any(text.endswith(p) for p in ['.', ',', ',', '!', '?', '。', '!', '?', '।', '۔'])
is_long_enough = word_count > 4
return (word_count >= 5 and (has_punctuation or is_long_enough))

三条规则各有针对性:

规则想留下 / 想丢掉
含数字就放行价格、日期、版本号、错误码——这些常常是答案本身
词数 ≥ 5丢掉 “Home” / “Login” / “Cookie” 这类导航碎词
标点表覆盖中日韩、天城文、阿拉伯语非拉丁语系页面也能过滤

妙在哪:不需要任何模型、不需要 DOM 语义分析,几行纯规则就把导航栏和页脚噪声削掉大半。代价是会误伤——短的表格单元、只有几个词的关键结论也会被丢。

链接的清洗

get_navigable() 收集所有 <a href> 并过两道筛(sources/browser.py:455-474):

  • is_link_valid()URL 超过 72 字符直接丢、必须有 scheme 和 netloc、路径以纯数字结尾的丢、图片和元数据扩展名的丢(sources/browser.py:437-453)。
  • clean_url():砍掉 # 锚点,query 参数只保留 _skw= / q= / s= 这三种可能承载语义的(sources/browser.py:420-435)。

「72 字符」是个硬编码的经验阈值——长 URL 往往是跟踪链接或深层动态页。副作用是合法的长文章链接也会被误杀。


4.6 导航决策:一页一次的「笔记 + 动作」

每到一个新页面,make_navigation_prompt() 拼出一份很长的 prompt(sources/agents/browser_agent.py:92-178),结构是:

【当前上下文】
页面 URL + 过滤后的 Markdown 正文
Allowed Navigation Links:未访问过的链接,带 [序号]
Inputs forms:页面上可填的表单字段
【指令】
1. 这页有用吗?有用就写 Note: <笔记>;没用就写 Error: <原因>
2. 去哪:说 "I will navigate to <完整URL>" 或说 GO_BACK
3. 要不要填表:用 [字段名](值) 的形式
4. 够了吗?够了说 REQUEST_EXIT
【示例】5 段示例:有用页 1 段、无用页 2 段、判定收工 1 段、填表 1 段
【收尾】重复一遍用户原始问题 + 已有的全部笔记

三个提示工程细节

① 笔记格式被强约束。 prompt 明写:

“Written as: ‘On <website URL>, <key fact 1>. <Key fact 2>.’ Avoid phrases like ‘the page provides’ or ‘I found that.’

因为这些笔记最后要被拼起来交给 LLM 写结论,元叙述(“这个页面提供了……”)是纯噪声。

② 用户原问题在开头和结尾各出现一次。 结尾那句 “Remember, user asked: {user_prompt}”(sources/agents/browser_agent.py:172-173)是对付「中间内容太长,模型忘了初衷」的常规手段。

③ 明确禁止求助。 “Never stop or ask the user for help.” —— 自主浏览一旦停下来问人,整个流程就断了。

顺手记一个瑕疵:示例编号撞号

那 5 段示例的抬头依次写成 Example 1 / 2 / 3 / 3 / 4——第三个编号用了两次sources/agents/browser_agent.py:146-170)。功能上无害(模型看的是内容不是序号),但和第 3 章 §3.8 里 planner 官方示例计划 id 撞号是同一类手抖,只是那一处会真的导致结果互相覆盖。

答案怎么被解析

parse_answer() 用一个状态机走行(sources/agents/browser_agent.py:220-235):

for line in lines:
if line == '' or 'action:' in line.lower():
saving = False # 空行或 "Action:" → 笔记段结束
if "note" in line.lower():
saving = True # 见到 note → 开始收
if saving:
buffer.append(line.replace("notes:", ''))
else:
links.extend(self.extract_links(line)) # 非笔记行才提取链接

笔记区里的链接不会被当成导航目标——这条区分很重要,否则模型在笔记里引用一个 URL,就会被误解成「我要去这里」。


4.7 表单填写

模型用 [字段名](值) 表达填表意图,正则 \[\w+\]\([^)]+\) 提取(extract_formsources/agents/browser_agent.py:60-64)。

浏览器侧的完整链路(Browser.fill_formsources/browser.py:752-769):

fill_form_inputs() 按名字找 xpath,按控件类型分别处理
| select → Select.select_by_visible_text,失败退回 by_value
| textarea → clear + send_keys
| file → 仅当是存在的绝对路径才 send_keys
| checkbox/radio → 当前状态 != 目标状态才 click
| 其他 → clear + send_keys
v
tick_all_checkboxes() 把页面上所有复选框都勾上(同意条款之类)
v
find_and_click_submission() 按 13 个候选词依次找按钮:
| login / submit / register / continue / apply /
| ok / confirm / proceed / accept / done / finish / start / calculate
v
wait_for_submission_outcome() 等 URL 变化或页面出现 "success"

字段定位靠 find_input_xpath_by_name,是子串匹配sources/browser.py:676-680):

for field in inputs:
if name in field["text"]:
return field["xpath"]

宽松匹配是为了容忍模型把 user[username] 写成 username,代价是可能撞上别的字段。

表单里的输入元素是靠注入 JS 收集的(sources/web_scripts/find_inputs.js),它会递归进 shadow DOM,并为每个元素算出 XPath——这一点比纯 Selenium 选择器强。

填完之后不是直接继续,而是再问一次 LLM「表单提交成功了吗」handle_form_fillinghandle_update_promptsources/agents/browser_agent.py:294-327):

“Does the page answer the user's query now? Are you still on a login page or did you get redirected?”

把「提交是否生效」的判断交给模型看新页面内容,而不是靠 HTTP 状态码。


4.8 反检测:三层叠加

做什么在哪
ChromeDriverundetected_chromedriver 替换标准 drivercreate_undetected_chromedriversources/browser.py:238-258
启动参数--disable-blink-features=AutomationControlled、随机 UA、固定 1920x1080、每次新建随机 profile 目录create_chrome_optionssources/browser.py:164-236
页面内 JScdc_* 变量、RTCPeerConnection 置 undefined、伪造 Notification 权限patch_browser_fingerprintsources/browser.py:342-344)注入 sources/web_scripts/spoofing.js

另外还有两处「装人」的行为:

  • human_scroll():随机滚动 13 次,每次 1501200 像素,40% 概率往回滚一点(sources/browser.py:333-340);go_to() 在导航前后各 sleep 一个随机时长,最后调它(sources/browser.py:346-376)。
  • 导航后会 WebDriverWait 等页面里不再出现 “checking your browser” 或 “captcha”,最多 10 秒(sources/browser.py:353-362)。

human_move() 那个方法(sources/browser.py:325-331是坏的——签名少了 self,函数体里用了未定义的 driver。代码里没有调用点,调了就会炸。

反过来:还要防网页搞你

每次 go_to() 成功后会顺手注入一段安全脚本(apply_web_safetysources/browser.py:363815-821),把硬件访问、自动播放、全屏、指针锁定、window.fetchwindow.prompt 这几样能力就地掐掉。具体禁了哪些、每一条在防什么,见 06-sandbox-and-safety.md §6.4「第 ④ 层」——那里也说清了它和上表 spoofing.js 的分工(一个防网站认出 agent,一个防网站坑本机)。

这里只提对浏览环节的直接后果:禁掉 fetch 会让重度依赖它的 SPA 在注入后瘫掉,页面正文抓不到东西,导航循环只能把这一页判成 Error 换下一个链接。


4.9 防打转的几个补丁

本地模型在导航里最容易犯的病是「原地打转」。代码里有四道补丁:

症状补丁位置
模型逐字重复上一轮答案换成 stuck_prompt,把上次的话贴回去说「换一个」sources/agents/browser_agent.py:335-345380-382
选了已访问过的链接select_link 跳过 search_history 里的237-249
选中当前页回退到搜索结果重选399-403
链接打不开提示「你之前选的站点不可用,考虑其他选项」411-420

还有一个容易看漏的关键动作——每轮循环开头 self.memory.clear()sources/agents/browser_agent.py:374):

while not complete and len(unvisited) > 0 and not self.stop:
self.memory.clear() # 只留 system prompt
...

也就是说浏览器 agent 每一页都是无记忆的:模型看到的只有系统提示 + 当前页 prompt。历史不靠上下文承载,全靠笔记数组 self.notes 承载,笔记在每次 make_navigation_prompt 时重新拼进去。

这是本项目里最值得学的一招:把长程记忆从「不断增长的对话历史」换成「一个由模型自己维护的结构化笔记列表」,上下文占用因此几乎恒定。


4.10 收尾:把笔记写成答案

循环退出后走 conclude_prompt()sources/agents/browser_agent.py:259-272):笔记编号后拼成列表,要求模型

“Expand on the finding or step that lead to success… Do not give advices or try to answer the human. Just structure the AI finding in a structured and clear way. You should answer in the same language as the user.”

两条约束:不要自由发挥(只准整理笔记,不准补充模型自己的知识)、用用户的语言回答(路由阶段把请求翻成了英文,笔记也是英文,这里要翻回去)。


4.11 边界

  • 完全没有视觉。 页面 canvas、图片里的信息、纯 JS 渲染出来但没进 DOM 文本的内容,一概看不见。Browser.screenshot() 拍的图只发给前端给人看(api.py:155-165),不进模型。
  • click_element 里有个未导入的异常名。 except ElementClickInterceptedException 出现在 sources/browser.py:491649,但文件顶部只从 selenium 导入了 TimeoutExceptionWebDriverException。真触发这个分支会变成 NameError
  • CAPTCHA 靠外部扩展。 stealth_mode 打开时会引导用户手动装 nopecha 扩展(load_anticatpcha_manuallysources/browser.py:317-323),本体不解验证码。
  • 一个 Browser 实例全局共享。 CLI 和 API 都只建一台 Chrome(api.py:106-109),顶层 BrowserAgent 和 planner 里的 BrowserAgent 共用它,所以浏览操作天然串行。

4.12 本章代码地图

主题文件符号
导航主循环sources/agents/browser_agent.pyBrowserAgent.process
搜索词生成sources/agents/browser_agent.pysearch_prompt
导航 promptsources/agents/browser_agent.pymake_navigation_promptmake_newsearch_prompt
笔记/链接解析sources/agents/browser_agent.pyparse_answerextract_linksextract_form
填表流程sources/agents/browser_agent.pyhandle_form_fillinghandle_update_prompt
结论生成sources/agents/browser_agent.pyconclude_prompt
页面转 Markdownsources/browser.pyBrowser.get_textis_sentence
链接清洗sources/browser.pyget_navigableis_link_validclean_url
表单执行sources/browser.pyfill_formfill_form_inputsfind_and_click_submission
驱动创建与隐身sources/browser.pycreate_drivercreate_chrome_optionspatch_browser_fingerprint
网页安全注入sources/browser.py + sources/web_scripts/inject_safety_script.jsapply_web_safety
输入元素扫描sources/web_scripts/find_inputs.jsfindInputsgetXPath
指纹伪装sources/web_scripts/spoofing.js
搜索工具sources/tools/searxSearch.pysearxSearch.execute
解析测试tests/test_browser_agent_parsing.py