AI面包君Learn · Build · Share
04Bread Browser · 第 4
总进度 25 / 33

Agent Loop(本课灵魂章)

让模型操作网页。Agent Loop(本课灵魂章),边读边运行配套 Python 代码。

2026-09-0760 分钟330 行代码难度 ★★★

1. 故事:从"一步"到"任务"

ch02 的 LLM 只能走一步——任务一多就跨不过来。比如"登录":

任务:登录 quotes.toscrape.com(用户名 admin / 密码 1234)

需要至少 4-5 步:

  1. 点 Login 链接
  2. 填用户名
  3. 填密码
  4. 点 Submit
  5. 验证 Logout 出现(= 登录成功)

每一步做完之后页面会变——LLM 必须看到新页面才能决定下一步。

ch03 加的就是这个循环:

while not done:
    snapshot = take_snapshot(page)
    action = ask_llm(task, snapshot, history)
    if action == done: break
    execute(action)

这跟 Bread Agent 的 chat 循环本质相同——只不过工具变成了 click / type,结果变成了"页面快照"而不是"文件内容"。


2. 跑起来

cd ch03_agent_loop
python main.py

预期输出

=== 任务 ===
Log into the website with username 'admin' and password '1234'. ...

=== Agent 开始 ===
  [step 0] click({'index': 1})           ← 点 Login
  [step 1] type_text({'index': 2, 'text': 'admin'})       ← 填用户名
  [step 2] type_text({'index': 3, 'text': '1234'})        ← 填密码
  [step 3] click({'index': 4})           ← 点 Submit
  [step 4] done({'answer': 'Successfully logged in...'})

=== Agent 最终回答 ===
Successfully logged in with username 'admin' and password '1234'. The "Logout" link is
visible on the page (index [1]), confirming the login was successful.

5 步全自动。最后浏览器停留 5 秒,这时往右上角看 —— Logout 链接。任务完成。


3. 逐行精讲

BrowserAgent —— 跟 Bread Agent 类一一对应

class BrowserAgent:
    MAX_STEPS = 15
    
    def __init__(self, client, model, page):
        self.client = client
        self.model = model
        self.page = page                    # ← 新成员:浏览器 page
        self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]

跟 Bread Agent 唯一区别:多了一个 self.page

  • Bread Agent 的工具是 "read_file / write_file / ..."
  • Bread Browser 的工具是 "click / type_text / press_key / done"

工具集换了——但循环架构完全一样。学完前面三课的人到这一章应该有强烈的"似曾相识"感。

主循环 —— 几乎是 Bread Agent 的拷贝

for step in range(self.MAX_STEPS):
    resp = self.client.chat.completions.create(
        model=self.model, messages=self.messages, tools=TOOLS_SCHEMA,
    )
    msg = resp.choices[0].message
    self.messages.append(msg)
    
    if not msg.tool_calls:
        return msg.content or "..."
    
    for call in msg.tool_calls:
        ...
        result = self._execute(name, args)
        self.messages.append({"role": "tool", "tool_call_id": call.id, "content": ...})

注意 if not msg.tool_calls: return——这就是停止条件。 LLM 决定"不调工具,直接说话"时,循环就结束。

ch02 我们用 tool_choice="required" 强制必须调工具。ch03 改成默认 auto——这样最后一步 LLM 可以不调工具、直接返回总结。

_build_tool_result —— 工具结果里塞"新快照"

def _build_tool_result(self, action_result: str) -> str:
    self.page.wait_for_load_state("domcontentloaded", timeout=5000)
    self.page.wait_for_timeout(400)
    new_snapshot = self._snapshot()
    return f"{action_result}\n\nCurrent page (URL: {self.page.url}):\n{new_snapshot}"

这是 ch03 跟 Bread Agent 最大的差异

Bread Agent 的工具返回:"读到了文件内容" / "写入成功"——LLM 拿到字符串就够了。

浏览器 agent 的工具返回必须包含新页面快照——因为:

  1. 点完 Login 链接,页面变了
  2. LLM 需要知道新页面长什么样才能决定下一步
  3. 每个 step 的快照不一样 → element 编号也不一样

如果你不把新快照塞回去,LLM 会用上一步的旧编号点东西——崩。

这一段代码是 agent loop 在浏览器场景下的特化点。理解了它就理解了 browser-use 的核心。

page.wait_for_load_state —— 等页面稳

self.page.wait_for_load_state("domcontentloaded", timeout=5000)
self.page.wait_for_timeout(400)

两道等待:

  • load_state("domcontentloaded"):等 DOM 解析完成(不包括图片资源)
  • wait_for_timeout(400):再额外等 400ms 给 JS 渲染时间

不等的话快照可能是点击瞬间的——还没跳页。

timeout=5000 兜底:如果 5 秒还没 domcontentloaded(罕见),try/except 直接跳过,等 400ms 后凭运气抽——不阻塞 agent。

生产级 agent 会监听 network idle、特定 selector 出现等更精细的信号。本课程简化。

system prompt 中的关键提示

"Indices in [N] refer to elements in the CURRENT snapshot. After every action, you "
"receive a NEW snapshot; the indices may have changed."

这条至关重要。不告诉 LLM 这件事,它会以为编号永远不变——直接用旧编号点新页面的元素,错。

"Don't repeat yourself. If an action didn't change the page, try a different one."

防止死循环——LLM 看到自己上次点 [5],页面没变,第二次还点 [5]。这条 prompt 让它避免重复。

MAX_STEPS = 15 兜底

MAX_STEPS = 15

15 步对大多数任务足够。如果你看 LLM 卡在某一步反复尝试,就是任务超出本架构能力——可能需要:

  • 加更明确的 system prompt
  • 把任务拆小
  • 引入子 agent(Bread Agent ch08 的模式)

4. 卡住了怎么办

❌ LLM 总在反复点同一个东西

通常是因为它看不到自己之前的尝试。检查:你是不是 messages 列表搞错了?每一步的 assistant message + tool result 都要 append 进 self.messages

❌ Selenium / Playwright 报 Timeout 30000ms exceeded

某次 .click() 等不到元素。可能原因:

  • 元素被 modal 遮住(要先关 modal)
  • 元素正在动画中
  • 你的 EXTRACT_JS 抽错了

定位办法:把 headless 改成 False 自己看着跑,肉眼定位卡哪一步。

❌ 中间步出错了,整个循环挂掉

我们已经包了 try/except —— action 失败会把错误信息当 tool result 喂回 LLM。LLM 会自己改方向("哦,刚才那个点失败了,让我试别的")。

这是 agent 鲁棒性的关键:错误信息也是上下文的一部分

❌ LLM 永远不调 done

可能任务描述里没有明确的成功信号。改进 system prompt:

"You MUST call `done` when you have completed the task. Don't keep clicking forever."

或者改进 task 描述:"...then call done."

❌ LLM 觉得登录失败了但其实成功了

quotes.toscrape.com 的登录页特别——成功后跳回首页,Login 链接变成 Logout。LLM 必须靠 snapshot 里的文字判断。如果 system prompt 没说"用 Logout 出现判断成功",LLM 可能误判。

任务描述里明确说出成功信号是最稳的。

❌ messages 列表越长 token 越多 → 越慢

每一步快照可能是 50-100 行。10 步累积 1000+ 行。生产级会做"只保留最近 3 步快照"的截断。本课程 ch04 起会引入这个。


5. 思考题

题 1:换任务

task 改成:

  • "Find the tag 'humor' and click it. Tell me how many quotes are on that page." → 需要点 humor 标签链接 + 计数 + 报告
  • "Search the site for quotes by Albert Einstein." → 网站没搜索功能,agent 会怎么办?(试试看)
  • "Log in, then log out." → 多步:登录 → 点 Logout → 验证

观察 LLM 在不同任务下的循环长度。

题 2:加 extract_text 工具

让 agent 主动读取页面里没出现在 interactive elements 里的文字(如 quote 内容)。

{
    "type": "function",
    "function": {
        "name": "extract_text",
        "description": "Extract the visible text of a non-interactive area by CSS selector.",
        "parameters": {
            "type": "object",
            "properties": {"selector": {"type": "string"}},
            "required": ["selector"],
        },
    },
},

执行:page.locator(args["selector"]).all_inner_texts(),返回字符串。

这一题为 ch04 做铺垫——任务变成"抓数据"时,必须能读非交互文字。

题 3:加日志 hook

参考 Bread Agent ch06 的 hooks 思路,在 _execute 前后插入打印:

  • 开始执行 → [before_tool] click(index=2)
  • 执行完毕 → [after_tool] took 230ms, new url: /login

time.time() 算耗时即可。

你会发现:每一步的耗时主要花在 LLM 响应(1-3s)而非浏览器操作(10-100ms)。这是为什么生产级 browser agent 不堆 LLM 调用次数。


下一章预告

ch04 把 agent 升级到能完成真正复杂的任务

"去 quotes.toscrape.com,把 humor 标签下所有引言抓下来,存成 JSON。"

涉及:多页面导航 + 翻页 + 抓非交互文字 + 数据汇总。代码量 ~430 行,是本课的全功能演示

本章完成后,继续装下一块能力