
做 Agent 的人常挂在嘴边一句话大模型长了一颗相当聪明的脑袋但缺一双手。它能帮你把方案写到滴水不漏却没法替你点一下按钮、填一个表单。这个项目的目标就是把这双手补上——做一个能自己打开 Chrome从登录、搜索、选定到最终确认完整跑完一条线上购票流程的 Agent。整个过程里你只需要给它一个任务描述剩下的点击、输入、翻页、判断都由模型自己去规划并执行。这篇文章适合三类人一是刚接触 Agent 开发、想弄明白“模型到底怎么操作浏览器”的初学者二是已经在接大模型 API、想做业务自动化的开发者三是做测试、做 RPA 的同行想看看传统的浏览器自动化和大模型驱动的自动化有什么本质区别。我会把架构拆开讲清楚再给出一份可以直接跑起来的最小实现最后把实操中踩过的坑列成清单。1. 先把问题想明白大模型缺的从来不是智力而是“执行力”1.1 大模型本质上还是一个“文本生成器”不管底层是 Transformer 还是 MoE大模型对外输出的东西只有一样token。也就是说当你问它“帮我买一张票”它能回答你一张票怎么买、价格大概多少、要注意什么但它自己不会真的打开购票网站去点几下。这不是模型笨而是架构决定的。模型的世界里只有文本序列没有鼠标、键盘、DOM 树这些东西。想让模型落地去做实际任务就得在它外面套一层“身体”——这个身体负责把模型的决策翻译成真实世界的动作再把真实世界的变化翻译回模型能理解的文本或图像。这个“身体”就是 Agent。我在设计这个项目时首先划清了一条边界模型只负责“想”不负责“做”。“想”包括理解任务、拆解步骤、选择动作、判断结果“做”包括打开页面、定位元素、点击输入、读取回执。两者之间通过一套结构化的协议通信。想清楚这条边界后面很多设计就顺了。1.2 为什么第一双手选择“浏览器控制”给模型装手可选的方向很多接 API 调第三方服务、写脚本操作数据库、控制桌面软件……我最终选了浏览器原因很朴素浏览器是数字世界的“通用终端”。生活里几乎没有哪个线上任务能脱离浏览器订机票酒店、填报名表、查账单、操作 OA 系统、在后台发文章。而且浏览器天然具备“可观测性”——页面上的文字、图片、按钮状态都是模型能感知到的信息。相比之下如果一个系统只有私有 API那你还得先研究它的接口文档、处理鉴权工作量大得多。浏览器自动化则不受限于某个平台的开放程度只要人能看到的页面Agent 理论上就能操作。另外选“购票”作为示范场景是因为它的任务链条足够完整打开站点、登录或保持会话、搜索、筛选、选择、填写信息、确认提交。每一步都有明确的页面状态变化非常适合用来展示 Agent 的“感知-决策-执行”闭环。把购票跑通了换成订会议室、批量填报、定时巡检这类任务代码框架基本不用动。2. 方案选型控制 Chrome 的路不止一条为什么最终选了 CDP2.1 三条主流路线的直观对比要和 Chrome 打交道行业内最常见的方案无非三类用 Selenium 或 Playwright 这类测试框架、写浏览器扩展、或者直连 Chrome DevTools ProtocolCDP。我把这几条路放在一起对比过差异非常明显方案原理优点缺点适合场景Selenium / Playwright通过 WebDriver 或自带协议驱动浏览器封装完善、API 友好、社区资料多多一层驱动依赖对页面状态的观测能力不如 CDP 直接传统自动化测试、爬虫浏览器扩展往页面注入 content script通过 background 通信可以直接操作 DOM能加 UI开发调试繁琐权限模型受限面向普通用户的浏览器插件CDPChrome 原生调试协议走 WebSocket零额外驱动、能力最全、可观测性最强偏底层需要自己封装Agent 开发、调试工具、精细化控制顺便说一句网上有人用 VBA 通过 CDP 操控 Chrome 做 Office 自动化原理也是这套协议。CDP 不挑语言只要你手里有一个 WebSocket 客户端就能用这给了上层设计很大的自由度。2.2 CDP 到底做了什么很多人每天都在用 CDP只是自己不知道——Chrome 的“开发者工具”就是通过 CDP 和浏览器内核通信的。你在 DevTools 里看到的 Elements 面板、Console、Network 请求、Performance 记录全部来自 CDP 暴露的接口。CDP 是一套基于 WebSocket 的 JSON-RPC 协议。你把命令编码成 JSON 发到浏览器的调试端口浏览器执行后把结果同样用 JSON 返回。它按“域”划分能力常用的大概这么几个Page负责页面生命周期比如导航、刷新、截屏Runtime在页面上下文里执行 JavaScript并取回返回值DOM查询和操作 DOM 树Network监听请求响应、模拟网络状态Emulation模拟设备、地理位置等环境对一个 Agent 来说Page 和 Runtime 基本就能覆盖 80% 的需求我用 Page.navigate 让浏览器跳转用 Runtime.evaluate 在页面里执行任意 JavaScript比如点击按钮、读取文本、修改输入框的值。剩下 20% 的需求比如判断页面是否加载完成、监听某个网络请求是否成功再按需打开 Network 等域就行。2.3 为什么 Agent 场景下 CDP 是最优选选 CDP 而不选 Selenium 这类封装框架不是因为它更“底层”就更好而是 Agent 场景有几个硬性要求CDP 满足得最漂亮。第一可观测性。Agent 需要频繁地把页面状态“读”给模型当前 URL 是什么、页面标题是什么、主要文本是什么、长什么样。CDP 不仅能截屏拿图片还能直接执行 JS 把 DOM 的文本和结构提取出来这比 WebDriver 拿元素属性要直接得多。第二会话可持久化。Chrome 启动时指定一个 user-data-dir登录态、Cookie 都会写进这个目录。下次再启动同一个目录登录状态还在。这对需要登录的购票任务太关键了省去了每次把账号密码交给 Agent 的麻烦也更安全。第三可控性。CDP 能监听到页面内部的报错、网络失败、对话框事件。Agent 判断“刚才那个动作到底成没成功”时这些信号比单纯看页面截图可靠很多。第四无额外依赖。Chrome 从很早就内置了调试端口能力不需要装驱动、不需要插扩展一条启动参数就能打开。部署成本几乎为零。3. 整体架构一个能买票的 Agent内部是怎么分工的3.1 感知层让模型“看见”页面Agent 要决策第一步是知道当前页面发生了什么。我们的感知层给模型提供两类信息视觉信息和文本信息。视觉信息就是截图。用 Page.captureScreenshot 把当前页面转成 JPEG/PNG 的 Base64 字符串。如果用的是 GPT-4o、Claude 这类多模态模型可以直接把图片塞给模型“看”它对版面、样式、按钮位置的理解非常准。成本相对高一些但对复杂页面效果最好。文本信息则是通过 JS 提取页面关键内容标题、当前 URL、正文 innerText、可见链接列表。为什么还要保留这层因为不是所有场景都用多模态模型纯文本模型的成本低、速度快而且对“读文字”这件事更精确。很多时候我会两条腿走路先拿文本信息给模型做主要判断只有遇到模型拿不准的视觉问题比如某张图片是不是验证码再补一张截图。这里有个关键经验不能把整个 DOM 树直接丢给模型。一个中型网页的 DOM 序列化后轻松超过几万 token模型上下文根本扛不住而且大部分节点对当前任务毫无意义。所以观察模块要做一次“信息压缩”只保留标题、URL、正文前若干字、链接的文字和地址。压缩策略直接决定任务能撑多长后面我会再展开。3.2 决策层用一套“动作协议”替模型划好边界模型不知道浏览器上有哪些操作可用所以你必须给它一份“工具说明书”并且要求它每次只按约定格式输出一个动作。这是 Agent 设计里最容易偷懒、却最不能偷懒的地方。我给模型定义的动作集大致如下goto跳转到指定 URLclick点击某个选择器或文本匹配到的元素type在指定输入框输入文本wait等待若干秒用于页面异步渲染extract读取页面关键信息比如价格、余票finish结束任务输出最终结论每个动作都有一个固定 JSON 结构比如 click 动作就是 {type: click, selector: #book-btn, reason: 找到预订按钮点击进入下一步}。我要求模型必须带 reason 字段理由很简单Agent 跑出问题时你能从 reason 里看出模型当时的判断依据这是在给系统留后路。提示词里还要写清楚约束条件一次只输出一个动作如果找不到元素不要反复重试同一个选择器建议先截屏或提取页面文本重新观察如果任务已经完成必须用 finish 明确结束。没有这层约束模型很容易陷入“原地打转”的死循环。3.3 执行层把动作翻译成 CDP 命令决策层输出的是高层动作执行层负责把它变成 CDP 命令。比如 goto 对应 Page.navigateclick 和 type 都是通过 Runtime.evaluate 在页面里执行一段 JS。这层设计有个容易被忽视的点动作执行完必须返回“回执”。回执里不仅要写“成功/失败”还要尽量附带可判断的信息比如找不到元素时把当前页面上有哪些可点击元素列出来。这样模型在下一步决策时就有了新的感知输入而不是面对一个冷冰冰的 false。我见过很多 Agent 项目在这层偷懒执行完动作直接返回 ok不把中间状态告诉模型。结果就是模型完全跟丢开始瞎猜。记住一句话执行层是模型感知的延伸。你让模型“看见”什么它就只能想什么。3.4 编排层循环、终止与状态管理Agent 主循环的本质是反复执行“观察 - 决策 - 执行 - 回读”这个序列直到模型输出 finish或者达到安全上限。安全上限很关键我一般会设两重最大步数和每步超时时间。状态管理方面对话历史不能无限增长。每次循环都会产生观察结果、模型输出、动作回执三段文本几十步下来就是一个巨大的上下文。我的做法是始终保留系统提示词和用户原始任务单轮观察结果做长度截断超过一定轮数后把早期的交互记录压缩成一条摘要。这样既保留了任务的连续性又不会让上下文失控。并发这块我的经验是一个任务独占一个 Chrome 实例。多个任务共享浏览器会互相干扰页面状态而且出了问题极难排查。如果有多任务需求就用进程级隔离每个任务起一个带独立 user-data-dir 的 Chrome。4. 核心实现从零开始搭一个最小可用的浏览器 Agent这一节是重点。我会按启动浏览器、封装 CDP、实现观察与执行、串起主循环的顺序把代码一步步写出来。代码用 Python依赖只需要 websockets 和 urllib没有任何额外的浏览器驱动。4.1 启动一个带调试端口的 Chrome 实例一切都要从一个带调试端口的 Chrome 开始。要让 CDP 生效启动 Chrome 时必须加上 --remote-debugging-port 参数最好再带上一个独立的 user-data-dir避免和日常浏览器环境冲突。import subprocess import time import urllib.request def launch_chrome(port9222, user_data_dir/tmp/agent_chrome_profile): # macOS 的 Chrome 路径 chrome_path /Applications/Google Chrome.app/Contents/MacOS/Google Chrome # Windows 可以换成rC:\Program Files\Google\Chrome\Application\chrome.exe # Linux 可以换成google-chrome cmd [ chrome_path, f--remote-debugging-port{port}, --no-first-run, --no-default-browser-check, f--user-data-dir{user_data_dir}, ] subprocess.Popen(cmd) # 轮询等待调试端口就绪 for _ in range(30): try: with urllib.request.urlopen(fhttp://localhost:{port}/json/version, timeout1) as resp: if resp.status 200: print(Chrome 调试端口已就绪) return except Exception: time.sleep(0.3) raise RuntimeError(Chrome 启动超时)user-data-dir 这个参数值得多说两句。它决定了 Chrome 把用户数据放在哪。我们做购票 Agent 时第一次人工登录一次购票网站登录态会写进这个目录下一次启动同一个目录Cookie 还在Agent 就直接是已登录状态。这就绕开了“怎么把账号密码安全地交给 Agent”这个麻烦问题。启动成功后通过 http://localhost:9222/json 可以拿到页面的 webSocketDebuggerUrl这个地址就是后续所有命令的入口。4.2 封装一个最小的 CDP 客户端CDP 命令的收发逻辑非常简单发一个带 id 的 JSON收一个带相同 id 的 JSON。麻烦的是命令响应是异步的中间可能夹着事件消息所以客户端需要按 id 做匹配。import asyncio import json import websockets class CDP: def __init__(self, ws_url): self.ws_url ws_url self.ws None self.msg_id 0 self.pending {} async def connect(self): self.ws await websockets.connect(self.ws_url) # 启用三个基础域 await self.send(Page.enable) await self.send(Runtime.enable) await self.send(DOM.enable) async def send(self, method, paramsNone): self.msg_id 1 mid self.msg_id await self.ws.send(json.dumps({ id: mid, method: method, params: params or {} })) while True: resp json.loads(await self.ws.recv()) if resp.get(id) mid: return resp.get(result, {})拿到 ws_url 之后只需要 CDP(ws_url).connect()这个对象就能收发所有 CDP 命令了。不要小看这段代码它已经足够支撑后面所有的操作。4.3 感知模块截屏 提取页面关键信息观察模块是整个 Agent 的信息入口。我把它拆成两个函数一个拿截图一个提取页面文本。async def capture_screenshot(cdp, quality70): result await cdp.send(Page.captureScreenshot, { format: jpeg, quality: quality }) return result[data] # Base64 字符串 async def extract_page_info(cdp): js () { const main document.querySelector(main) || document.body; const text (main.innerText || ).slice(0, 3000); const links Array.from(document.querySelectorAll(a)) .slice(0, 30) .map(a ({ text: (a.innerText || ).trim().slice(0, 50), href: a.href })); return { title: document.title, url: location.href, text: text, links: links }; } expr f({js})() result await cdp.send(Runtime.evaluate, { expression: expr, returnByValue: True }) return result[result][value]这里用了 returnByValue: True意思是让浏览器把 JS 执行结果转成 JSON 值返回而不是返回一个远程对象引用。这是做页面信息提取时最容易踩的坑之一忘记这个参数的话你拿到的只是一串对象 ID用不了。文本截取 3000 字符、链接取前 30 个是我在实际项目中调出来的折中数值。太少模型看不清页面全貌太多上下文迅速膨胀。你可以根据任务复杂度调整但要有意识地做这个“截断”。4.4 动作执行模块点击、输入、跳转和等待执行模块按动作类型分发到不同处理函数。导航走 Page.navigate点击和输入走 Runtime.evaluate 注入 JS等待就直接 sleep。import json as json_mod async def execute_action(cdp, action): atype action.get(type) if atype goto: await cdp.send(Page.navigate, {url: action[url]}) await asyncio.sleep(2) # 给页面首屏一点时间 return {ok: True, msg: f已跳转 {action[url]}} if atype click: selector action[selector] js f () {{ const el document.querySelector({json_mod.dumps(selector)}); if (!el) return {{ok: false, error: 元素不存在}}; el.click(); return {{ok: true}}; }} result await cdp.send(Runtime.evaluate, { expression: f({js})(), returnByValue: True }) time.sleep(1) return result[result][value] if atype type: selector action[selector] text action[text] js f () {{ const el document.querySelector({json_mod.dumps(selector)}); if (!el) return {{ok: false, error: 输入框不存在}}; el.value {json_mod.dumps(text)}; el.dispatchEvent(new Event(input, {{bubbles: true}})); el.dispatchEvent(new Event(change, {{bubbles: true}})); return {{ok: true}}; }} result await cdp.send(Runtime.evaluate, { expression: f({js})(), returnByValue: True }) return result[result][value] if atype wait: await asyncio.sleep(action.get(seconds, 1)) return {ok: True, msg: f等待 {action.get(seconds, 1)} 秒} if atype finish: return {ok: True, finish: True, summary: action.get(summary, )} return {ok: False, error: f未知动作类型: {atype}}点击和输入的实现都是通过 querySelector 找到元素再去操作。这里有个细节对于输入框直接设置 value 之后必须手动派发 input 和 change 事件。因为很多前端框架Vue、React是监听这些事件来同步状态的光改 value 不会触发框架的数据更新后续提交时拿到的还是空值。这是用 JS 注入方式操作页面的经典坑网上很多教程都没提到。4.5 Agent 主循环把感知、决策、执行串成闭环主循环的结构很清晰观察、调模型、解析动作、执行、回读结果循环直到 finish 或步数耗尽。async def run_agent(task, llm_client, max_steps20): ws_url get_ws_url(9222) cdp CDP(ws_url) await cdp.connect() history [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: task} ] for step in range(max_steps): print(f--- Step {step 1} ---) # 1. 观察 screenshot_b64 await capture_screenshot(cdp) page_info await extract_page_info(cdp) # 2. 组装给模型的输入这里按纯文本模型示例 observation { url: page_info[url], title: page_info[title], page_text: page_info[text][:2000], action_history_tail: history[-4:], } # 3. 决策 resp_text await llm_client.chat(history [ {role: user, content: json_mod.dumps(observation, ensure_asciiFalse)} ]) action parse_action(resp_text) # 解析 JSON 动作 # 4. 执行 result await execute_action(cdp, action) history.append({role: assistant, content: resp_text}) history.append({role: user, content: f动作结果: {json_mod.dumps(result, ensure_asciiFalse)}}) # 5. 终止判断 if result.get(finish): print(任务完成:, result.get(summary)) return result if result.get(ok) is False: # 连续失败多次可以直接中止避免死循环 print(动作执行失败:, result.get(error)) print(达到最大步数任务终止) return {ok: False, error: max_steps_exceeded}parse_action 需要把模型输出解析成结构化动作。我的经验是让模型直接输出纯 JSON不要用 Markdown 代码块包裹。解析时先尝试 json.loads如果失败用正则把json 和之间的内容剔出来再 load。这一步要写得健壮因为模型偶尔不听话。get_ws_url 从调试端口拉取页面目标地址逻辑非常简单请求 http://localhost:9222/json找到第一个 type 为 page 的 target取其 webSocketDebuggerUrl 即可。4.6 把这套代码接上 LLM上面代码里 llm_client 是一个抽象接口实际使用时可以接任意厂商的兼容接口。下面是接一个 OpenAI 兼容接口的示例import openai class OpenAICompatClient: def __init__(self, base_url, api_key, model): self.client openai.OpenAI(base_urlbase_url, api_keyapi_key) self.model model async def chat(self, messages): resp self.client.chat.completions.create( modelself.model, messagesmessages, temperature0 ) return resp.choices[0].message.content这里把 temperature 设为 0是刻意为之。Agent 执行任务时我们需要的是稳定、可复现的动作选择而不是发散创意。温度越高模型越容易突发奇想输出一些协议之外的东西。本地部署场景也可以用 Ollama 或 vLLM 起一个兼容服务然后把 base_url 指过去。模型参数不一定要很大我试过 7B 级别的本地模型配合这套框架跑简单任务能完成一部分复杂页面还是吃力一些。如果条件允许优先用旗舰级模型成功率会高很多。5. 实操中踩过的坑与排查实录5.1 元素定位失败别把所有希望押在 selector 上Agent 在页面上找元素最直接的方式是 querySelector。但真实页面远比 demo 复杂按钮文案会变、元素 class 是动态生成的、同一个选择器可能匹配到多个节点。我遇到过很多次模型兴奋地输出一个 click 动作结果元素不存在任务直接卡住。后来我的解法是在感知层就把页面里可交互的元素“喂”给模型而不是让模型盲猜。具体做法是在提取页面信息时额外收集 button、input、a 这些标签的关键属性连同可见文本一起返回。模型做动作时优先从这些“备选元素”里挑成功率直线上升。另外等待策略特别重要。很多页面是异步渲染的按钮在 DOM 里不是你一导航就出现。我给每个 click 和 type 动作都预留了重试机制第一次找不到元素时等待 1 到 2 秒再找一次仍找不到才返回失败。这避免了大量由于渲染延迟导致的误判。5.2 登录、验证码与人机校验合规永远是第一位的购票类网站通常有人机校验。这里我必须说清楚一个原则Agent 做自动化不是用来绕过平台风控的。这套框架的目的是帮助你在自己有权操作、平台允许的范围内提升效率而不是去突破验证码、暴力抢票。实际项目中我的做法是“人机分工”登录态通过独立 user-data-dir 保存首次人工登录一次后续 Agent 直接复用。遇到平台强制要求验证Agent 停止执行通知人工处理。高并发、压秒抢票不建议做。一方面这对其他用户不公平另一方面也违反了多数平台的服务条款容易导致账号被封。合规的自动化重点在于“自动完成重复性操作”而不是“突破平台限制”。这在做 Agent 产品时是一条必须守住的红线。5.3 上下文窗口与长任务信息压缩比想象力更重要购票流程看起来不长但每一步都要交换多轮信息。如果全程把完整页面文本、完整动作回执都塞进上下文二十步之后就算是最新的长上下文模型也会开始丢信息输出质量明显下降。我的处理策略是分层压缩页面文本始终截断到 3000 字符以内对 Agent 来说读段落主干比读全篇更重要。链接列表只保留前 30 个并且带上链接文字。模型决策时主要看文字是否匹配任务目标很少需要全部链接。动作历史只保留最近 4 轮。更早的历史合并成一句摘要“此前已完成 XXX下一步目标是 YYY”。截图使用 JPEG 低质量压缩70 的质量参数肉眼完全可读token 消耗却少一大截。这套压缩方案落地后同样的任务上下文体积能降到原来的三分之一任务成功率也更高。5.4 任务卡死、超时与异常恢复Agent 跑着跑着没动静是最常见的事故。我排查过的问题大概有几类页面弹出了原生对话框alert/confirmJS 执行被阻塞。对策是启动时监听 Page.javascriptDialogOpening 事件自动点击“确定”。网络请求挂起导致页面一直转圈。对策是给观察和执行都加超时超时就强制刷新或中止本轮动作。模型连续重复同一个错误动作。对策是加一个“重复动作检测”如果最近的 3 个动作完全相同且都失败直接让 Agent 进入“重新观察”状态而不是继续蛮干。主循环里的 max_steps 是最后一道保险。不管前面怎么调超过步数必须强制终止把控制权交还给人。宁可任务没完成也不能让 Agent 挂在那里空转。5.5 调试技巧留好每一轮的“审计日志”Agent 开发最大的难题是它不像普通程序可以打断点你根本不知道模型为什么会在某一步做出奇怪的选择。我的做法是给每一轮循环都记录审计日志包含观察摘要、模型原始输出、解析后的动作、执行结果四段。定位问题时把日志从头翻一遍基本能还原模型当时的“思考过程”。这四段里最容易被忽略的是“模型原始输出”。很多开发者直接把解析后的动作存下来模型当时到底说了什么反而没留。但正是这段原始输出能看出模型是不是理解错了页面、是不是在纠结某个选择、是不是被你某段提示词误导了。把日志做全排查效率至少翻一倍。最后再分享一个小技巧给模型喂页面文本时我会在开头加一行当前 URL 和页面标题。这两个信息对模型判断“自己在哪一步”帮助极大。很多时候模型迷失方向不是因为它笨而是你连最基本的“位置信息”都没给它。这个项目的后续空间其实很大。同一套“感知-决策-执行”框架把浏览器操作换成 HTTP 请求、数据库查询、文件读写就能演变成企业内部的信息处理 Agent。再往下走引入多模态模型直接看截图、加入任务记忆和反思机制就是一个接近商用水平的 Agent 雏形。建议你从今天这个最小框架开始先把“用模型控制浏览器”这双手练熟再去扩展更复杂的四肢。