练习 30:浏览器——agent 的另一双手
练习 29 给 web_fetch 的工具描述里写了一句"JS 渲染的页面只能拿到静态 骨架"。这一章就是那句话的下一站:页面要跑 JS 才有内容、要登录才给看、 要点按钮才往下走的时候,HTTP GET 帮不上忙,你需要一个真正的浏览器。
这是全书最后一个新工具:browser,通过 Chrome DevTools Protocol
(CDP)驱动你本机的 Chrome。CDP 听起来吓人,拆开看一点都不神秘:一条
websocket,上面跑 JSON-RPC——发 {"id":1,"method":"Page.navigate",...},
等 {"id":1,"result":...} 回来。和练习 22 的 MCP 客户端同一个套路,
只是对面从一个工具服务器换成了浏览器。协议里没有任何魔法,魔法全在
Chrome 里。
它也是全书最高危的一个工具。它连的是你自己的浏览器,可能带着你的 登录态——它的每一次点击,都是以你的身份在真实网站上做真实动作。这个 分量贯穿整章的每个设计决定。
敲进去
在练习 29 的代码上继续写。动手之前先办一件全书没办过的事:
go get github.com/gorilla/websocket
这是本书的第一个第三方依赖。手写了二十九章之后在这里破例,理由要说 清楚:websocket 的握手、帧格式、掩码规则是传输层的细节,跟"agent 怎么 用工具"这条主线一个字都不沾边,手写它只会把这一章变成网络编程教程。 octo 用的也是这个库。判断标准和练习 29 选 ripgrep 时一样——聪明的 工具知道什么时候该把活儿外包,聪明的书也一样。
CDP 客户端:练习 22 的老朋友
// cdpMessage 是 CDP websocket 上的一帧。三种身份共用一个结构:带 id 的
// 请求、带同一个 id 回来的响应、id 为零的事件广播。
type cdpMessage struct {
ID int64 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params any `json:"params,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *cdpError `json:"error,omitempty"`
}
客户端本体和练习 22 的 MCP 客户端长得几乎一样:自增 id 发命令,响应
按 id 找回等待的调用方。多出来的东西只有一个 sessionId——它区分
"说给谁听":空是浏览器级命令(开 tab、关 tab),带值是页面级命令
(导航、执行 JS、截图)。
type cdpClient struct {
conn *websocket.Conn
writeMu sync.Mutex // gorilla 要求写方自己串行化
nextID atomic.Int64
mu sync.Mutex
pending map[int64]chan cdpMessage
closeOnce sync.Once
closed chan struct{}
closeErr error
}
读循环里有这一章最重要的一笔简化:
func (c *cdpClient) readLoop() {
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
c.shutdown(err)
return
}
var msg cdpMessage
if json.Unmarshal(data, &msg) != nil || msg.ID == 0 {
continue // 事件(没有 id 的帧)直接丢:我们不订阅,全靠轮询
}
c.mu.Lock()
ch := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.mu.Unlock()
if ch != nil {
ch <- msg
}
}
}
Chrome 会在这条连接上主动广播事件——页面加载完了、新 tab 开了、有 请求发出去了。octo 有一整套订阅机制去消费它们,录制回放全靠这个。 本书把所有"等待某件事发生"都改成了轮询(下面 navigate 你会看到), 事件一律丢弃。少一套广播机制,代价是每次等待多花几十毫秒。这是一笔 自觉的交换,不是偷工。
call 的骨架你在练习 22 已经写过一遍,唯一值得停下来看的是收尾那个
select:ctx 断了立刻返回(练习 24 立的规矩,打断要能穿透工具),
连接死了也立刻返回——shutdown 会关掉所有还在等的 channel,把死讯
广播给每个等待中的调用方,不让任何人吊在一条已经断了的线上。
连接:浏览器得是用户主动交出来的
先回答一个问题:连谁的 Chrome?
答案是用户自己的,而且必须是用户主动交出来的。这个立场写在代码
结构里:connectChrome 从不自己启动 Chrome,只连一个已经开着调试
端口的实例,发现不了就明说怎么开。
// connectChrome 拨号浏览器级 websocket。注意它从不自己启动 Chrome:
// 这个工具只驱动用户主动交出来的浏览器,"主动"体现在那个勾选框或者
// 那个命令行参数上。发现不了就明说怎么开,而不是替用户做决定。
"交出来"有两条路,对应 cdpEndpoint 里的两个分支:
// cdpEndpoint 找到浏览器级 CDP websocket 的地址。经典的
// --remote-debugging-port 启动会开一个 /json/version HTTP 端点,
// webSocketDebuggerUrl 一读就有;而 chrome://inspect 勾选框那条路只开
// websocket、不开 /json(访问是 404),地址得从 DevToolsActivePort 文件
// 里读——第一行是端口,第二行是带 UUID 的 websocket 路径。两条路都
// 试过才认输。
func cdpEndpoint(ctx context.Context, port int) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("http://127.0.0.1:%d/json/version", port), nil)
if resp, err := http.DefaultClient.Do(req); err == nil {
var v struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
_ = json.NewDecoder(resp.Body).Decode(&v)
resp.Body.Close()
if v.WebSocketDebuggerURL != "" {
return v.WebSocketDebuggerURL, nil
}
}
for _, dir := range chromeProfileDirs() {
data, err := os.ReadFile(filepath.Join(dir, "DevToolsActivePort"))
if err != nil {
continue
}
lines := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)
if len(lines) == 2 {
return "ws://127.0.0.1:" + strings.TrimSpace(lines[0]) + strings.TrimSpace(lines[1]), nil
}
}
return "", errors.New(browserSetupGuide)
}
这两条路值得记住,因为背后是 Chrome 这几年的一次安全收紧:Chrome 136
起,--remote-debugging-port 指向默认用户目录(也就是你登录着各种
账号的那个 profile)时不再生效——不能再用一个命令行参数就把别人日常
浏览器的钥匙拿走。想交出带登录态的浏览器,得在 Chrome 里亲手勾一个
勾(chrome://inspect/#remote-debugging),而且之后每个新的调试连接
还会弹一次授权提示。拒过一次,拨号就会收到 HTTP 403:
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusForbidden {
// 勾选框那条路对每个新连接都要用户在 Chrome 里点一次
// "允许";拒过一次,之后的拨号就是这个 403。
return nil, fmt.Errorf("Chrome 拒绝了这次调试连接(403)。在 Chrome 弹出的授权提示里点允许,再试一次\n%s", browserSetupGuide)
}
return nil, fmt.Errorf("拨号 %s: %w", wsURL, err)
}
conn.SetReadLimit(64 << 20) // 一张截图就能超过默认的读取上限
一路都是闸门:勾选框、重启、授权弹窗。麻烦是设计出来的——能操作你 登录态的东西,就该这么难拿到。
tab:开自己的,不碰用户的
// newTab 开一个新 tab 并附着上去。永远开自己的 tab,绝不劫持用户正开
// 着的——cookie 和登录态是整个 profile 共享的,新 tab 照样带着登录,
// 但用户正看着的那个页面不会被我们导航走。
func newTab(ctx context.Context, cli *cdpClient) (*page, error) {
res, err := cli.call(ctx, "", "Target.createTarget", map[string]any{"url": "about:blank"})
...
res, err = cli.call(ctx, "", "Target.attachToTarget", map[string]any{
"targetId": created.TargetID,
// flatten 让页面命令和浏览器命令走同一条 websocket,只多带一个
// sessionId 字段——不开的话得再走一层嵌套的消息封装
"flatten": true,
})
...
p := &page{cli: cli, sessionID: attached.SessionID, targetID: created.TargetID}
for _, domain := range []string{"Page.enable", "Runtime.enable"} {
if _, err := cli.call(ctx, p.sessionID, domain, nil); err != nil {
return nil, fmt.Errorf("%s: %w", domain, err)
}
}
return p, nil
}
开 tab、附着、打开两个协议域,三步。Runtime.enable 之后就能在页面
里跑 JS 了,而"在页面里跑 JS"是接下来一切能力的地基:
// eval 在页面里执行一段 JS 表达式,把返回值解进 out(不关心就传 nil)。
// 这是整个页面层的地基:下面的导航等待、找元素、observe,全是 eval 的
// 不同用法。
func (p *page) eval(ctx context.Context, expr string, out any) error {
res, err := p.cli.call(ctx, p.sessionID, "Runtime.evaluate", map[string]any{
"expression": expr,
"returnByValue": true,
"awaitPromise": true,
})
...
}
页面里的 JS 抛了异常,eval 把它翻译成 Go 错误再交给模型——CDP 返回的 异常描述是"错误消息 + 整条堆栈",只留第一行,别拿一坨堆栈灌上下文。 往表达式里拼字符串的地方全部走一个小函数:
// jsStr 把 s 编码成 JS 字符串字面量。strconv.Quote 对引号、反斜杠和
// 控制字符的转义恰好也是合法的 JS 语法,拼进表达式不会被内容注破。
func jsStr(s string) string { return strconv.Quote(s) }
navigate:没有事件,就轮询两段
// navigate 加载 url,然后等页面就绪。octo 订阅 Page.loadEventFired
// 事件;我们没有事件,用两段轮询代替:先等导航"离开"出发页(href 变了,
// 或者 eval 报错——旧文档正在拆),再等新文档的 readyState 走到 complete。
func (p *page) navigate(ctx context.Context, url string) error {
var start string
_ = p.eval(ctx, "location.href", &start)
if start == "" || start == "about:blank" {
// 起点是我们自己开的空白 tab:用 location.replace 把它从历史里
// 顶掉。不这么做,模型之后一个"后退"会退回空白页,然后对着一个
// 什么都没有的页面困惑。
if err := p.eval(ctx, fmt.Sprintf("(()=>{location.replace(%s); return true})()", jsStr(url)), nil); err != nil {
return err
}
} else if _, err := p.cli.call(ctx, p.sessionID, "Page.navigate", map[string]any{"url": url}); err != nil {
return err
}
// 第一段:等导航提交。等不到不算失败——也可能是导去了同一个 URL。
...
// 第二段:等新文档加载完。eval 报错(文档正在换)当"还没好"继续轮。
...
}
为什么要两段?"加载完"的标志是 document.readyState == "complete",
但旧文档的 readyState 也是 complete——导航刚发出、新页面还没接管
的那一瞬间,你问 readyState 得到的是旧页面的答案。先确认"已经离开",
再确认"已经到了",两个问题都问过才算数。
observe:给没有眼睛的模型的"看"
现在到了这一章真正的分水岭。浏览器打开了,模型怎么"看"页面?
本书两端的模型——deepseek-v4-flash 和 qwen3:4b-instruct——都没有 视觉。给它们一张截图,等于给盲人一幅画。octo 的答案是把页面翻译成 文字:
// observe 返回页面的文本摘要:URL、标题、可交互元素清单,每个元素带
// 一条能直接喂给 click/type 的 CSS 选择器。这是给没有眼睛的模型准备的
// "看"——页面在模型那里从来不是像素,是这份清单。
//
// 选择器的生成有优先级:有 id 用 id,有 data-testid/name/aria-label 这类
// 语义属性用属性,都没有才退到 nth-of-type 链——越靠前的越稳定,页面
// 改版了还能用。这段 JS 蒸馏自 octo 的 InteractiveDigest。
干活的是一段注入页面的 JS:扫 a,button,input,select,textarea 和几个
交互性 role,过滤掉不可见的,每个元素生成一条选择器和一段可读文本。
两个细节都是实战伤疤:
// 可见 = 有布局盒且没被 visibility 藏起来。不用 offsetParent 判断
// ——position:fixed 的导航栏和悬浮按钮 offsetParent 是 null,但
// 明明点得到(octo 踩过的坑)。
以及输出的最后一行:
// 截断要说话——练习 29 给 grep 立的规矩,observe 同样要守。不说,
// 模型会把"清单里有 19 条结果"当成"页面上只有 19 条结果"(真机
// 实验里真的发生了:页面明明写着 30 条,它数了清单就作答)。
if digest.Total > len(digest.Items) {
fmt.Fprintf(&sb, "(页面上共有 %d 个可交互元素,这里只列了前 %d 个——要数总量或读正文,用 eval)\n", digest.Total, len(digest.Items))
}
这条规矩是怎么被逼出来的,"你应该看到什么"的实验二里有完整过程。
click:真手势,不是合成事件
找元素、算坐标:
// elementCenter 找到选择器命中的第一个元素,滚进视野,返回中心点坐标。
// 三种失败分开报:选择器语法不合法、合法但匹配不到、匹配到了但那个点
// 够不着。报错都把模型往下一步引——报错也是 prompt,练习 21 的老规矩。
//
// "够不着"(hittable 检查)值得单独说:坐标点击落在的是屏幕上的一个点,
// 不是 DOM 里的一个节点。元素在视口外(坐标是负数)、或者被加载遮罩、
// 弹层、折叠的侧栏挡着,click 都会发得一声不响、什么都没发生——模型
// 看到"已点击",页面却纹丝不动,下一步就开始瞎猜。elementFromPoint
// 问的正是"这个点上真正收到点击的是谁",不是目标就直说。
然后是点击本身:
// click 在元素中心补一次真实的鼠标手势:移动、按下、抬起,走 CDP 的
// Input 域。为什么不 eval 一句 el.click() 了事?因为那是合成事件,
// isTrusted 是 false——文件选择框这类只认真手势的流程不理它,反爬脚本
// 也拿它当自动化的招牌。Input 域发出的事件和真实鼠标在页面眼里没有
// 区别。
//
// 按下之前先移动、移动之后停一拍,是从 octo 抄来的实战伤疤:不少控件
// 在指针进入时才把自己"武装"起来(pointerenter 的处理器还常排在
// requestAnimationFrame 后面),移动和按下之间不留缝,按下就被控件当
// 没发生。buttons:1 是真实按下时的按键位掩码,检查它的框架会把 0 当成
// 合成事件。
func (p *page) click(ctx context.Context, selector string) error {
x, y, err := p.elementCenter(ctx, selector)
if err != nil {
return err
}
if _, err := p.cli.call(ctx, p.sessionID, "Input.dispatchMouseEvent", map[string]any{
"type": "mouseMoved", "x": x, "y": y,
}); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(clickMoveSettle):
}
for _, ev := range []map[string]any{
{"type": "mousePressed", "x": x, "y": y, "button": "left", "buttons": 1, "clickCount": 1},
{"type": "mouseReleased", "x": x, "y": y, "button": "left", "buttons": 0, "clickCount": 1},
} {
if _, err := p.cli.call(ctx, p.sessionID, "Input.dispatchMouseEvent", ev); err != nil {
return err
}
}
return nil
}
输入分两个动作,因为网页世界里"输入"真的是两件事:
// typeText 聚焦到目标元素,把文本"输"进去。Input.insertText 相当于一次
// 输入法上屏:内容整段进去,触发 input 事件,但不产生逐键的
// keydown/keyup。对绝大多数表单够用;只认逐键事件的控件要补真实按键,
// 加分练习里有。
// pressKey 发一次真实按键:keyDown + keyUp。type 的 insertText 不产生
// 逐键事件,而不少控件只认逐键——防抖搜索框监听的是 keyup,表单靠
// Enter 提交。keyDown 上带的 text 是让按键执行"默认动作"的开关:不带,
// Chrome 只发 DOM 事件不干活(Enter 不提交表单、空格不打空格)——
// octo 注释里记着的坑,原样搬过来。
screenshot 是七个动作里最短的:Page.captureScreenshot 拿回 PNG,
落盘到 .screenshots/,把路径交给模型。图片本身不进对话——octo
在这里按模型能力分岔,有视觉的模型收到真正的图片内容块,没有的只收到
路径和一句"用 observe"。我们的模型都没有视觉,所以只有后一半。
会话:一条连接省着用
// theBrowser 是全局唯一的浏览器会话:一条 CDP 连接 + 一个我们自己开的
// tab,所有 browser 调用共享。全局不是偷懒,是刻意的:navigate 完再
// click,模型期望的是"同一个页面";而且勾选框那条路每次新拨号都要用户
// 在 Chrome 里点一次授权,连接能复用多久就该复用多久。
取用的时候三层探活,一层比一层贵:
// browserPage 拿到当前可用的页面,没有就建。三层探活,一层比一层贵:
// tab 还活着直接用;tab 死了(用户随手关了)在原连接上开个新的——不用
// 重新拨号,也就不会再弹授权;连接也死了(Chrome 整个重启了)才重连。
退出时把借的东西还回去。exitREPL 里加一行:
// 借的 tab 也要还——只关我们自己开的那一个,Chrome 是用户的进程,
// 一根手指都不碰。
closeBrowserTab()
工具:一个名字,七个动作
func (browserTool) definition() toolSpec {
return toolSpec{
Name: "browser",
Description: "驱动本机一个真实的 Chrome 完成网页任务:导航、查看页面、点击、输入、执行 JS、截图。" +
"连接用户自己的浏览器,可能带着登录态。只在任务真的需要操作网页界面时用;" +
"已知 URL 的公开内容,web_fetch 更便宜。动手之前先用 observe 看清页面上有什么。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"navigate", "observe", "click", "type", "key", "eval", "screenshot"},
...
},
...
},
"required": []string{"action"},
},
}
}
注意形状:一个工具,七个动作,靠 action 枚举分发——而不是七个
独立工具。上一章 bash 后台重构走的是反方向,terminal_output 和
terminal_input 拆成了两个工具。什么时候合、什么时候拆?分界线是
状态。七个浏览器动作操作的是同一个页面、同一条连接、同一个"现在
看到哪儿了",合在一个工具里,这份共享状态就有唯一的看门人;而两个
terminal 工具虽然也共享后台进程表,但读输出和喂输入是两种性质的动作
(一个只看、一个改变进程状态),值得在工具名这一层就区分开。octo 的
browser 工具是同一个形状,二十一个动作复用一个名字。
execute 的开头有一个不起眼但真机验证过的护栏:
// 参数用错不能静默忽略。小模型爱把两步压成一步——observe 带上 url,
// 指望"打开顺便看"。工具要是不吭声地丢掉 url,模型就会对着一个从没
// 导航过的空白 tab 得出"网站打不开"的结论(真机实验里真的发生了)。
// 报错纠正,比替它脑补一次 navigate 更能把它拉回正轨。
if in.URL != "" && in.Action != "navigate" {
return fmt.Sprintf("错误: url 参数只属于 navigate——先 {\"action\":\"navigate\",\"url\":...} 打开页面,再单独调 %s", in.Action)
}
每个动作套一个 45 秒的上限(browserActionTimeout):页面卡住时一次
CDP 调用可能永远等不到回音,超时报错好过整轮挂死。
注册还是一行,位置有讲究:
// browser 也在 subAgent 之后——整个进程共享一条 CDP 连接、一个我们
// 自己开的 tab,也就是只有一个"光标位";并行的分身会互相把对方正
// 看着的页面导航走。真要并行,得一个分身一个 tab,那是加分练习的事。
toolList = append(toolList, browserTool{})
最后,一个被推翻的前提。练习 5 定下的每轮请求上限一直是 10,这一章 提到 30:
// maxRounds 这一章从 10 提到 30——又一个被推翻的前提。10 是文件任务的
// 尺码:读一个文件、改两行、跑个测试,三五轮收工。浏览是"看一眼、动
// 一下"的循环,一次点击前后常各挂着一轮 observe,撞上折叠侧栏这种弯路
// 再多烧两三轮,10 轮经常卡在马上要作答的那一步(真机实验里两次撞上)。
// octo 的这个上限是 1000——它防的是失控的死循环,不是长任务;本书取 30,
// 够跑完一个带弯路的网页任务,也仍然兜得住失控。
const maxRounds = 30
10 这个数字当年没写错,是"一句话的活儿三五轮收工"这个前提被浏览器 推翻了。设计决定会随前提失效——练习 26 说过一遍的话,最后一个工具又 演了一遍。
跑起来
先给 harness 一个能连的 Chrome。两种方式:
方式 A:日常 Chrome(带你的登录态)。 地址栏打开
chrome://inspect/#remote-debugging,勾选 "Allow remote debugging for
this browser instance",重启浏览器。之后 harness 第一次拨号时 Chrome
会弹授权提示,点允许。
方式 B:独立实例(干净、无登录态,适合先练手)。 终端执行:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 --user-data-dir=/tmp/harness-chrome \
--window-size=1280,900
Linux 把可执行文件换成 google-chrome,Windows 换成 chrome.exe 的
完整路径。端口不是 9222 就设 CDP_PORT 环境变量。本章的实验都用
方式 B——公开网站不需要登录态,而且你可以亲眼看着 agent 在窗口里
点来点去。
然后照常:
export OPENAI_API_KEY=sk-xxxx
export MODEL=deepseek-v4-flash
export OPENAI_BASE_URL=https://api.deepseek.com/v1
go run . "用 browser 工具打开 https://leihb.github.io/learn-agent-the-hard-way/ ,在侧边栏找到练习 23(沙箱)那一章并点开它,告诉我这一章的标题和第一段讲什么"
对,让它浏览这本书自己。
你应该看到什么
实验一:点开一章(以及模型的"后门")
DeepSeek 跑上面那个任务,9 轮收工。前半段完全是教科书:
[round 1] browser({"action": "navigate", "url": "https://leihb.github.io/learn-agent-the-hard-way/"})
[浏览器已连接,开了自己的 tab——不动你已经打开的任何页面]
[round 2] browser({"action": "observe"})
[round 3] browser({"action": "click", "selector": "nav > div:nth-of-type(1) > ol > li:nth-of-type(39) > a"})
observe 列出了侧边栏全部章节链接,模型拿着现成的选择器去点练习 23。 但 round 3 的 click 返回的是报错:
元素 "nav > ... > a" 找到了,但它所在的点当前点不到(在视口外,或被
别的元素挡着)——它可能藏在折叠的侧栏/菜单里,先点 observe 清单里
toggle 类的开关把它展开,再点它
这是 hittable 检查在干活。实验用的 Chrome 视口不宽,这个宽度下 mdBook 把侧边栏整体平移到了屏幕外——链接的坐标是负数,坐标点击必然落空。 没有这个检查,工具会返回"已点击",页面纹丝不动,模型从此开始瞎猜。
接下来的部分值得逐轮看。模型没有听报错的建议去点开关,它走了后门:
[round 4] browser({"action": "eval", "js": "document.querySelector('nav > ... > a').scrollIntoView({block:'center'}); ..."})
[round 5] browser({"action": "click", "selector": "nav > ... > a"}) ← 还是点不到
[round 6] browser({"action": "eval", "js": "document.querySelector('nav > ... > a').click(); document.title"})
[round 7] browser({"action": "eval", "js": "window.location.href"})
[round 8] browser({"action": "eval", "js": "document.title + ... + Array.from(document.querySelectorAll('main p')).slice(0,2)..."})
round 6 那一句 el.click()——我们精心设计的"真手势"纪律,被 eval
一行 JS 绕过去了。合成点击不需要坐标,元素藏在屏幕外也照样触发导航。
在这个站上它成功了,模型顺利读到正文,最后交出的答案完全正确:
章节标题: 练习 23:沙箱——把 bash 关进笼子
第一段大意: 练习 9 给 bash 加了一道权限闸门,它检查的是命令 字符串……但这道闸门有一个结构性的漏洞:它只认得它见过的字符串。
任务完成了,但你应该记住 round 6:给了 eval,就等于给了绕过其他 所有纪律的能力。"发生了什么"里回来算这笔账。
实验二:搜索,以及一条被逼出来的规矩
第二个任务:用页面自带的搜索功能搜 bash,报告结果条数和第一条。 DeepSeek 的操作链干净利落:
[round 3] browser({"action": "click", "selector": "#search-toggle"})
[round 5] browser({"action": "type", "selector": "#searchbar", "text": "bash"})
[round 6] browser({"action": "key", "keys": "enter"})
[round 7] browser({"action": "observe"})
type 和 key 的分工在这里现出原形:mdBook 的搜索框监听的是逐键的
keyup 事件,insertText 把 "bash" 填进了框里但什么都不会发生——防抖
搜索框根本不知道有人输入了。补一个真实的 enter 按键,搜索才跑起来。
(另一次跑里模型在 type 之后先 observe 了一眼,发现没有结果,还试图
screenshot"亲眼看看"——收到的是"当前模型看不了图片"的提示——然后
才想起补回车。没有眼睛的模型在迷茫时也会伸手够那只它没有的眼睛,这个
细节比任何论述都生动。)
round 7 的 observe 输出的结尾,是这一章新立的那行字:
(页面上共有 76 个可交互元素,这里只列了前 60 个——要数总量或读正文,用 eval)
模型看到这行,round 8 改用 eval 数了 #searchresults li,答案:
搜索结果条数:30 条 第一条结果:练习 23:沙箱——把 bash 关进笼子
正确。这行提示不是一开始就有的——它是被同一个实验的上一次跑逼出来的。第一版 observe 到了 60 个元素就默默停手,什么都不说。那次跑里模型数了清单里 的 19 条结果就作答——"搜索结果:19 条"——而页面标题栏明明写着 30。清单被截断了,模型把看到的当成了全部。练习 29 给 grep 立的规矩 (截断要说话,不然模型把片段当全集)在自己家的新工具上被违反了一次, 被真机实验当场抓获。修完重跑,模型顺着提示去 eval 数总量,答案从 19 变成 30。
实验三:本机小模型,和一个被报错救回来的误会
qwen3:4b-instruct(本机 Ollama)跑一个简单任务:打开书站,observe, 说出侧边栏最后一章的名字。它第一轮就翻了个熟悉的车:
[round 1] browser({"action":"observe","url":"https://leihb.github.io/learn-agent-the-hard-way/"})
把两步压成一步——observe 顺便带个 url,指望"打开顺便看"。第一版工具 静默忽略了这个用不上的参数,于是它 observe 到的是那个从没导航过的 空白 tab,然后一本正经地下结论:
页面没有可交互元素,可能是因为网站当前无法加载内容……建议检查网站 是否正常运行。
网站好得很,是它自己没导航。加上参数护栏("url 只属于 navigate"的 报错)之后重跑,同一个模型第一轮还是犯同样的错,但这次收到的是纠正 而不是沉默:
[round 1] browser({"action":"observe","url":"..."}) ← 报错:url 只属于 navigate
[round 2] browser({"action":"navigate","url":"..."})
[round 3] browser({"action":"observe"})
一轮拉回正轨,答案正确。
实验四:小模型走正路,大模型走后门
最后把实验一的任务换成 qwen3:4b 跑一遍(点开练习 5)。它同样在折叠的 侧边栏上撞了 hittable 报错,接下来的选择和 DeepSeek 完全不同:
[round 3] browser({"action":"click","selector":"nav > ... > li:nth-of-type(13) > a"}) ← 点不到
[round 4] browser({"action":"click","selector":"#sidebar-toggle"})
[round 5] browser({"action":"observe"})
[round 6] browser({"action":"click","selector":"nav > ... > li:nth-of-type(13) > a"}) ← 成了
它老老实实照报错的指引点开了侧栏开关,再点目标,一次成功。同一条 报错,大模型拿它当参考(然后用 eval 自作主张),小模型拿它当指令。 "报错也是 prompt"这条规矩,对越小的模型越是刚性约束——它没有富余的 聪明去发明别的路,你在报错里铺的那条路就是它的全部选项。
发生了什么
页面在模型那里不是像素,是一份清单
这一章最容易被低估的动作是 observe。它做的事说穿了很简单——把 DOM 里能点能填的东西列成文字——但它回答的是浏览器自动化的第一个问题: 模型怎么知道页面上有什么?
流行的答案是截图加视觉模型。那条路真实存在(octo 的 screenshot 就是 为它准备的),但它有门槛:模型得有眼睛,一张图几百上千 token,而且 看得见不等于点得着——视觉模型看到"搜索按钮",还是得有人把它翻译成 一个可以点击的目标。observe 直接跳过整个翻译问题:清单里的每一项自带 选择器,看见即可操作,任何模型都能用。文本摘要是下限,视觉是锦上添花 ——octo 把两者做成两个动作解耦,谁有能力谁加菜。
清单思路的代价是清单有边界:只列"可交互"的元素,读不到正文(要 eval
document.body.innerText);有数量上限,超了要说话。实验二里 19 对
30 的翻车说明,这份清单就是模型的整个世界——清单的缺陷会原样变成
模型的认知缺陷。你设计 observe 输出的每一行,就是在设计模型的眼睛。
真手势的纪律,和 eval 这扇后门
click 走 Input 域、按下前先移动、带上 buttons 位掩码——这些讲究只为 一件事:让页面无法分辨这次点击和真人的区别。hittable 检查则守着另一 半诚实:点不到就报错,绝不假装点过。
然后实验一里,模型用 el.click() 把这套纪律整个绕过去了。
要不要堵?octo 没堵,本书也不堵,但要把账算明白。eval 是浏览器工具里 能力密度最高的动作:读正文靠它、数元素靠它、够不着 CSS 选择器的 shadow DOM 也只有它能进——砍掉它,工具的可用性掉一半。而它的全部 能力恰恰来自"在页面里跑任意 JS",这个定义本身就包含了合成点击。你 不可能只给"好的任意"不给"坏的任意"。真正的边界要看场景:在配合的 网站上(比如你自己的站、内部系统),合成点击无伤大雅;在有反爬的 网站上,isTrusted 是 false 的点击是自动化的招牌,模型自作聪明的一次 绕行可能换来一次封号。这笔账应该记在心里,而不是记在代码里——因为 代码堵不住它。
连的是谁的浏览器,就是谁在承担后果
这个工具的每个安全决定都指向同一个事实:浏览器里装的是用户的 身份。cookie、登录态、支付方式,全在那个 profile 里。
所以 Chrome 要求用户亲手勾选、亲手授权,一个连接一次;所以工具从不 自己启动浏览器、从不劫持用户开着的 tab、退出只关自己那一个;所以 工具描述里写着"可能带着登录态",提醒模型这不是沙盘。还有一层要 诚实交代:反爬检测是一场没有保证的军备竞赛,真手势、真 profile、 像人的节奏都只是降低概率,有的网站照样认得出自动化,代价可能是 封你的账号——这个风险属于用户,工具能做的是不加戏(不用合成 事件、不用无头指纹),以及把风险说出来,而不是假装它不存在。
还记得练习 23 点破的"进程内的工具不过沙箱"、练习 29 里 web_fetch
自觉检查网络开关吗?browser 是这个名单上的第三个洞,而且是最大的
一个:Chrome 是独立进程,它的出网既不经过 harness 的沙箱,也不看
allowNetwork 开关——沙箱把 bash 的网络关得死死的,Chrome 转身就
能替模型把任何东西发出去。练习 9 的权限闸门也一样只认 bash 命令
字符串,click 和 navigate 从它眼皮底下过,它看都不看。把浏览器动作
接进权限闸门,是加分练习的第一题,也是你真要在生产环境用这个工具
之前必须做的一题。
全书最后一个工具,还是那个形状
数一遍这一章的零件:一份 toolSpec 声明、一个 execute 函数、注册表
里一行。CDP 客户端三百行,但那是工具的里子——从模型的视角,
browser 和练习 5 的 read_file 长得一模一样:一个名字,一份参数说明,
调用,拿结果。声明里教用法("动手之前先用 observe"),报错里铺路
("先点 toggle 类的开关"),参数护栏防它抄近道,上限说话防它误判——
全书教过的每一课,在最重的这个工具上各就各位。
agent 的另一双手,依然只是一个 tool 设计决定。这是本书最后一次说这 句话,下一章回望。
常见问题
Chrome 弹了授权提示,我点了拒绝,现在一直 403。
Chrome 记住了你的选择。去 chrome://inspect/#remote-debugging 把勾
去掉再勾上,重启浏览器,重新连——这次点允许。或者干脆用方式 B 的
独立实例,那条路没有授权弹窗。
授权明明点了允许,harness 还是被 Connection rejected 秒拒,而且
不再弹窗。
先查一件事:是不是有另一个 CDP 客户端正连着这个 Chrome。实测
(Chrome 151)的行为是:一次授权在整个 Chrome 会话内有效,但同一
时刻只放一个客户端——第一个拨号的工具把坑占了,后来者一律秒拒、
不弹窗,看起来和"被拒绝授权"一模一样。用 lsof -nP -iTCP:9222 看看
谁在 ESTABLISHED,把那个工具退了(或者让它断开),下一次拨号就直接
进来,连弹窗都不用再点。这也是"跑起来"推荐方式 B 的又一条理由:
独立实例不跟你桌面上的其他自动化工具抢坑。
为什么不支持 --remote-debugging-port 直连我的日常 Chrome?
Chrome 136 之前可以,之后不行了——这个参数指向默认用户目录时会被
忽略,因为默认目录里装着你的全部登录态,一个命令行参数就能拿走它
太危险。勾选框(加授权弹窗)就是官方给的替代路径。这不是本书实现的
局限,是 Chrome 的安全决定,任何 CDP 工具都要过这一关。
本机 Ollama 报 exceeds the available context size (4096)。
harness 长大了:三十章攒下来的工具声明加 system prompt 已经超过
Ollama 默认的 4096 上下文。给 Ollama 服务设 OLLAMA_CONTEXT_LENGTH=16384
再重启它(brew 用户:launchctl setenv OLLAMA_CONTEXT_LENGTH 16384 && brew services restart ollama)。这个报错本身是个里程碑——你的
harness 第一次大到默认配置装不下了。
搜索中文关键词,结果永远是 0。 先分清"工具坏了"和"页面就是这样"。mdBook 自带的搜索引擎不会切分 中文词,搜"沙箱"真的就是 0 条——工具忠实转达了页面的行为。判断方法 和人一样:换一个英文词试试("bash" 有 30 条),或者 eval 看看页面上 的提示文字("No search results for...")。工具的职责是把页面如实翻译 给模型,页面本身的短板不归它修。
click 返回"已点击",但页面看起来什么都没变。
两种可能。一是点击开了一个新 tab(target=_blank)——我们的实现
留在原页面上,模型看到的确实没变。octo 的 ClickFollow 会检测新 tab
并跟过去,本书没搬,加分练习里有。二是 SPA 更新是异步的,点完立刻
observe 可能看到旧内容——隔一轮再看,或者 eval 里轮询目标元素。
screenshot 到底有什么用?我们的模型又看不了。
给人看(.screenshots/ 里的 PNG 你自己打得开,调试利器),以及给
未来留位置——换一个有视觉的模型,这个动作立刻从"存文件"升级成
"真的看见"。octo 按模型能力分岔的设计说明这不是假设,是现役功能。
加分练习
- 把浏览器动作接进权限闸门。 navigate/click/type 都是对外的真实
动作,比一条 bash 命令的分量重得多,却不经过练习 9 的批准流程。
给 browserTool 的 execute 加一道
confirm(走练习 25 的 askCh, 并发安全是现成的),高危动作先问人。想好哪些动作要问:observe 和 eval 读页面要不要拦?eval 能el.click(),它真的是"只读"吗? - ClickFollow:跟上点击开出的新 tab。 点击前
Target.getTargets记一份快照,点击后再取一次,多出来的 page 就是新 tab——attach 上去 换掉当前 page。octo 还会核对新 tab 的 openerId 防止抓错,想想什么 场景会抓错。 - 修饰键组合。 pressKey 现在只有单键。参考 octo 的写法加
ctrl+a、cmd+shift+s:modifiers 是一个位掩码(alt=1、ctrl=2、 meta=4、shift=8),注意组合键的 keyDown 不能带 text——ctrl+a是 全选,不是打一个 a。 - 一个分身一个 tab。 browser 现在排在 subAgent 之后注册,分身 拿不到它。把全局的单 page 会话改成"每个调用方一个 tab"(连接仍然 共享),就能把 browser 塞进子 agent 的工具集,并行抓取多个页面。 想清楚谁负责关 tab。
- 读一读 octo 的录制回放。 本章实现的是"模型驱动浏览器",octo
还有另一半:"人示范、编译成可回放的 YAML、回放失败才叫模型来修"
(
internal/browser/recorder.go、recording.go)。那是把浏览器 自动化从"每次都靠模型"变成"确定性回放 + 模型兜底"的路,也是 替代脆弱 RPA 的思路。读懂它的 self-heal 入口在哪,你就看懂了 "确定性优先,智能兜底"这个高级形态。