做 Agent 的人常挂在嘴边一句话:大模型长了一颗相当聪明的脑袋,但缺一双手。它能帮你把方案写到滴水不漏,却没法替你点一下按钮、填一个表单。这个项目的目标,就是把这双手补上——做一个能自己打开 Chrome,从登录、搜索、选定到最终确认,完整跑完一条线上购票流程的 Agent。整个过程里,你只需要给它一个任务描述,剩下的点击、输入、翻页、判断,都由模型自己去规划并执行。
这篇文章适合三类人:一是刚接触 Agent 开发、想弄明白“模型到底怎么操作浏览器”的初学者;二是已经在接大模型 API、想做业务自动化的开发者;三是做测试、做 RPA 的同行,想看看传统的浏览器自动化和大模型驱动的自动化有什么本质区别。我会把架构拆开讲清楚,再给出一份可以直接跑起来的最小实现,最后把实操中踩过的坑列成清单。
1. 先把问题想明白:大模型缺的从来不是智力,而是“执行力”
1.1 大模型本质上还是一个“文本生成器”
不管底层是 Transformer 还是 MoE,大模型对外输出的东西只有一样:token。也就是说,当你问它“帮我买一张票”,它能回答你一张票怎么买、价格大概多少、要注意什么,但它自己不会真的打开购票网站去点几下。
这不是模型笨,而是架构决定的。模型的世界里只有文本序列,没有鼠标、键盘、DOM 树这些东西。想让模型落地去做实际任务,就得在它外面套一层“身体”——这个身体负责把模型的决策翻译成真实世界的动作,再把真实世界的变化翻译回模型能理解的文本或图像。这个“身体”,就是 Agent。
我在设计这个项目时,首先划清了一条边界:模型只负责“想”,不负责“做”。“想”包括理解任务、拆解步骤、选择动作、判断结果;“做”包括打开页面、定位元素、点击输入、读取回执。两者之间通过一套结构化的协议通信。想清楚这条边界,后面很多设计就顺了。
1.2 为什么第一双手选择“浏览器控制”
给模型装手,可选的方向很多:接 API 调第三方服务、写脚本操作数据库、控制桌面软件……我最终选了浏览器,原因很朴素:浏览器是数字世界的“通用终端”。
生活里几乎没有哪个线上任务能脱离浏览器:订机票酒店、填报名表、查账单、操作 OA 系统、在后台发文章。而且浏览器天然具备“可观测性”——页面上的文字、图片、按钮状态,都是模型能感知到的信息。相比之下,如果一个系统只有私有 API,那你还得先研究它的接口文档、处理鉴权,工作量大得多。浏览器自动化则不受限于某个平台的开放程度,只要人能看到的页面,Agent 理论上就能操作。
另外,选“购票”作为示范场景,是因为它的任务链条足够完整:打开站点、登录或保持会话、搜索、筛选、选择、填写信息、确认提交。每一步都有明确的页面状态变化,非常适合用来展示 Agent 的“感知-决策-执行”闭环。把购票跑通了,换成订会议室、批量填报、定时巡检这类任务,代码框架基本不用动。
2. 方案选型:控制 Chrome 的路不止一条,为什么最终选了 CDP
2.1 三条主流路线的直观对比
要和 Chrome 打交道,行业内最常见的方案无非三类:用 Selenium 或 Playwright 这类测试框架、写浏览器扩展、或者直连 Chrome DevTools Protocol(CDP)。我把这几条路放在一起对比过,差异非常明显:
| 方案 | 原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| Selenium / Playwright | 通过 WebDriver 或自带协议驱动浏览器 | 封装完善、API 友好、社区资料多 | 多一层驱动依赖;对页面状态的观测能力不如 CDP 直接 | 传统自动化测试、爬虫 |
| 浏览器扩展 | 往页面注入 content script,通过 background 通信 | 可以直接操作 DOM,能加 UI | 开发调试繁琐,权限模型受限 | 面向普通用户的浏览器插件 |
| CDP | Chrome 原生调试协议,走 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:跳转到指定 URL
- click:点击某个选择器或文本匹配到的元素
- type:在指定输入框输入文本
- wait:等待若干秒,用于页面异步渲染
- extract:读取页面关键信息(比如价格、余票)
- finish:结束任务,输出最终结论
每个动作都有一个固定 JSON 结构,比如 click 动作就是 {"type": "click", "selector": "#book-btn", "reason": "找到预订按钮,点击进入下一步"}。我要求模型必须带 reason 字段,理由很简单:Agent 跑出问题时,你能从 reason 里看出模型当时的判断依据,这是在给系统留后路。
提示词里还要写清楚约束条件:一次只输出一个动作;如果找不到元素,不要反复重试同一个选择器,建议先截屏或提取页面文本重新观察;如果任务已经完成,必须用 finish 明确结束。没有这层约束,模型很容易陷入“原地打转”的死循环。
3.3 执行层:把动作翻译成 CDP 命令
决策层输出的是高层动作,执行层负责把它变成 CDP 命令。比如 goto 对应 Page.navigate,click 和 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(port=9222, user_data_dir="/tmp/agent_chrome_profile"): # macOS 的 Chrome 路径 chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" # Windows 可以换成:r"C:\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(f"http://localhost:{port}/json/version", timeout=1) 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, params=None): 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, quality=70): 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_steps=20): 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_ascii=False)} ]) 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_ascii=False)}"}) # 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_url=base_url, api_key=api_key) self.model = model async def chat(self, messages): resp = self.client.chat.completions.create( model=self.model, messages=messages, temperature=0 ) 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/confirm),JS 执行被阻塞。对策是启动时监听 Page.javascriptDialogOpening 事件,自动点击“确定”。
- 网络请求挂起导致页面一直转圈。对策是给观察和执行都加超时,超时就强制刷新或中止本轮动作。
- 模型连续重复同一个错误动作。对策是加一个“重复动作检测”:如果最近的 3 个动作完全相同且都失败,直接让 Agent 进入“重新观察”状态,而不是继续蛮干。
主循环里的 max_steps 是最后一道保险。不管前面怎么调,超过步数必须强制终止,把控制权交还给人。宁可任务没完成,也不能让 Agent 挂在那里空转。
5.5 调试技巧:留好每一轮的“审计日志”
Agent 开发最大的难题是:它不像普通程序可以打断点,你根本不知道模型为什么会在某一步做出奇怪的选择。我的做法是给每一轮循环都记录审计日志:包含观察摘要、模型原始输出、解析后的动作、执行结果四段。定位问题时,把日志从头翻一遍,基本能还原模型当时的“思考过程”。
这四段里最容易被忽略的是“模型原始输出”。很多开发者直接把解析后的动作存下来,模型当时到底说了什么反而没留。但正是这段原始输出,能看出模型是不是理解错了页面、是不是在纠结某个选择、是不是被你某段提示词误导了。把日志做全,排查效率至少翻一倍。
最后再分享一个小技巧:给模型喂页面文本时,我会在开头加一行当前 URL 和页面标题。这两个信息对模型判断“自己在哪一步”帮助极大。很多时候模型迷失方向,不是因为它笨,而是你连最基本的“位置信息”都没给它。
这个项目的后续空间其实很大。同一套“感知-决策-执行”框架,把浏览器操作换成 HTTP 请求、数据库查询、文件读写,就能演变成企业内部的信息处理 Agent。再往下走,引入多模态模型直接看截图、加入任务记忆和反思机制,就是一个接近商用水平的 Agent 雏形。建议你从今天这个最小框架开始,先把“用模型控制浏览器”这双手练熟,再去扩展更复杂的四肢。