智能体框架的惨痛教训
所有价值都在经过强化学习训练的模型中,而不在你那一万行抽象代码里。
智能体只是消息的 for 循环。智能体唯一应该拥有的状态是:持续运行,直到模型停止调用工具。你不需要智能体框架。你不需要其他任何东西。它只是一个工具调用的 for 循环。
我们最初的 Browser Use 智能体有数千行抽象代码。它们能用——直到我们试图改动任何东西。每次实验都在与框架对抗。智能体失败不是因为模型笨,而是因为我们笨。
请看到最后,我会向你展示构建 Claude Code 有多容易。
为什么抽象会阻碍学习
关于抽象的问题在于:它们固化了关于智能应如何运作的假设。RL 打破了这些假设。
每次你为模型行为添加一个"智能"包装器——规划模块、验证层、输出解析器——你都在编码你认为模型应该做什么。但模型是在数百万个样本上训练的。它见过的模式比你能预料的更多。你的抽象变成了约束,阻止模型使用它所学到的东西。
ML 研究中的苦涩教训很明确:利用计算的通用方法每次都能击败手工打造的人类知识。智能体框架只是这个错误的最新实例。
99% 的工作在模型内部
事实是:99% 的工作是在模型内部完成的。我们不需要在它周围套一个高度抽象的框架。
Claude Code 现在可以直接编写 AppleScript。它需要从某个冷门的 Spotify 播放器获取信息?它不需要 Spotify 的 computer-use 工具。它直接在 macOS 上编写 AppleScript。它拥有完美的上下文。它在这方面训练得很好。
你无法预料每一个用例。模型已经知道了。
关键洞察
这引出了一个重要的结论:
智能体框架失败不是因为模型能力弱,而是因为它们的动作空间不完整。
不要预先定义每一个可能的动作,而是从相反的假设出发:模型几乎可以做任何事。然后再加以限制。
尽可能给 LLM 自由,然后基于评估进行 vibe 限制。
为什么我们把一切都扔掉了
Browser Use 的第一个版本是一个经典的智能体框架:一个模型被包裹在一个复杂的消息管理器中,带有大量旨在控制行为的抽象。它能用,但扩展起来很痛苦。每次实验都在与框架对抗。添加新能力感觉像是在违背苦涩教训。(公平地说,模型从去年以来进步了很多)
所以我们退后一步,问了一个更根本的问题:
LLM 实际上被训练得极其擅长什么——并且随着模型变好,什么会保持不变?
我们把整个智能体扔掉,从头开始。为了理解"最小化"真正意味着什么,我们逆向工程了 Claude Code 和 Gemini CLI。向他们致敬,创造了真正优秀且基本简洁的原语。尽管它们内部很复杂,但底层思想很简单:
不要过度指定智能——让模型去推理。
BU Agent:应用
我们将这一理念融入了 BU Agent——一个驱动 Browser Use 的极简智能体框架。
BU Agent 没有暴露一小组脆弱的"点击/输入/滚动"原语,而是让模型访问原始的浏览器控制界面。
核心在于:能够发出纯 Chrome DevTools Protocol(CDP)指令。实际上,模型几乎可以在浏览器中做任何事情。
在此之上:浏览器扩展 API。它们让某些仅靠 CDP 会很别扭或根本不可能的任务变得轻而易举——比如访问活动窗口或操作带权限的浏览器状态。
CDP 和扩展 API 各有盲区,但结合起来就构成了一个近乎完整的动作空间。
当模型拥有这种自由时,一件重要的事情就会发生。如果一种方法失败,它会绕过去。如果一个工具坏了,它会找到另一条路。
只要原则上一切皆有可能,LLM 就极其擅长在运行中自我修复。
反转
所以 BU Agent 是基于一个简单的反转构建的:
先赋予最大能力,然后再加以限制。
赋予模型做人类在浏览器中能做的任何事情的自由。只有在此之后,才叠加安全性、结构和约束。
正是这一点让系统能够随着更好的模型而扩展,而不是与它们对抗。
我讨厌其他所有 LLM 框架
说真的。它们实现 LLM 对象的方式令人痛苦。
所以我写了我自己的。超级简单的调用方式。它们就只做这些——针对 Anthropic、OpenAI 和 Google。根据我们的遥测数据,这些占了 95% 的用例。
class ChatAnthropic:
async def ainvoke(self, messages, tools) -> ChatCompletion: ...
class ChatOpenAI:
async def ainvoke(self, messages, tools) -> ChatCompletion: ...
class ChatGoogle:
async def ainvoke(self, messages, tools) -> ChatCompletion: ...相同的接口。完全掌控缓存、序列化、提供商怪癖。没有魔法。没有意外。
自己做缓存和实现消息要容易得多。完全与模型无关。你不会被锁定在某一个提供商上。你说了算。
临时消息
浏览器代理需要的一件有趣的事情:如果你请求浏览器状态,它非常庞大。DOM 快照、截图、元素索引——每次请求轻松超过 50KB。
没有临时消息,情况是这样的:10 次浏览器交互后,上下文里就有 500KB 的状态。20 次后,达到 1MB。模型开始失去连贯性。它忘记了原始任务。它幻觉出已经不存在的元素。最终你会撞上上下文限制,整个系统崩溃。
所以我引入了临时消息。
@tool("Get browser state", ephemeral=3) # Keep last 3 only
async def get_state() -> str:
return massive_dom_and_screenshot如果你调用工具 X 次(由你定义),它会移除所有之前的输出。会稍微破坏缓存。但这是一个非常好的权衡——反正 LLM 也处理不了巨大的上下文。模型只需要最近的状态;旧的浏览器快照就是噪音。
for 循环不起作用(直到你修复它)
朴素的方法——当模型返回没有工具调用时停止——效果不好。代理会过早结束。尤其是当它们缺少某些上下文时。你想要一个后续交互,但如果你使用的是这类 API,这是不可能的。
最好的修复方法是 done 工具。
@tool('Signal that the current task is complete.')
async def done(message: str) -> str:
raise TaskComplete(message)当模型输出 done 工具调用时,它会终止代理。这强制要求显式完成,而不是隐式的"我猜我们完成了?"
我们有两种模式:
- CLI 模式:当 LLM 返回没有工具调用时停止(快速交互)
- 自主模式:仅在显式调用 done() 时停止
Claude Code 就是这样做的。Gemini CLI 也是这样做的。现在你知道它为什么存在了。
但你需要可靠的基础设施
是的。for 循环很简单。让它健壮就不简单了:
- 带指数退避的重试
- 速率限制处理
- 连接恢复
- 上下文压缩
- Token 追踪
那是运维。已解决的问题。必要——但不要把它和代理本身混为一谈。
残酷的真相
每一个抽象都是一种负担。每一个"辅助工具"都是一个故障点。
这些模型变得很强了。真的很强。它们在计算机使用、编码、浏览方面经过了强化学习训练。它们不需要你的防护栏。它们需要:
苦涩的教训:你构建得越少,它就运作得越好。
我们将其开源为 agent-sdk。
如果你想,可以在生产环境中使用它,但最好只是把上下文粘贴到 Claude Code 中,自己做一个。用你正在使用的任何编程语言。仓库中还包含一个 Claude Code 重新实现的示例。
总之,这就是我们在构建 bu.app 时学到的东西。试试看吧。太棒了!!