☰
Jev Ultrafast浏览器Agent:动作选择题范式与工程实现
2026/9/26 5:18:07 网站建设 项目流程

如果你最近在折腾浏览器 Agent,应该对 Jev Ultrafast 这个名字不陌生。过去我们做网页自动化,思路基本只有一条:让模型自己生成代码或者指令,然后由执行器去跑。方向没错,但用起来真是状况百出——模型写出一段 Playwright 脚本,跑起来不是选择器失效就是点击被遮挡;改成自然语言指令吧,解析器又常常会把话说岔,轻则原地空转,重则把表单填得乱七八糟。Jev Ultrafast 换了一个视角:模型根本不需要知道怎么操作浏览器,它只需要像考试一样,从我们提前列好的几个动作选项里选出最合理的那一个。整个 Agent 的推理过程从“生成一段话”变成了“做一道选择题”,速度快、结果可控,这也是它名字里 Ultrafast 的由来。这篇文章我会从设计思路讲到原理,再给出可以直接参考的代码骨架和实战踩坑记录,适合想自建浏览器 Agent、又不想被模型自由发挥折磨的开发者。

1. 浏览器 Agent 的两条路线:让模型写代码,不如让模型做选择

1.1 传统方案为什么总是“看起来很美”

先说大多数人入门的做法:让大模型直接生成自然语言指令,再用一个解析器把指令翻译成浏览器调用。比如模型说“点击右上角的登录按钮”,解析器就得判断“右上角”是坐标还是语义位置,还要处理页面上可能同时出现多个“登录”入口的情况。页面稍微复杂一点,解析器很容易产生歧义,模型还浑然不知,继续往下输出指令,结果错误越积越多。

第二种做法是让模型直接生成 Playwright 或 Puppeteer 代码。这个方案看着很酷,但实测下来问题最集中。模型生成的await page.click('button:has-text("登录")')在静态页面上可能没问题,可现代页面大量使用异步渲染,按钮还没挂载到 DOM 上,脚本就先执行了。模型不知道页面加载速度,也没法预知网络延迟,于是经常生成一段“理论上正确、跑起来必挂”的代码。更麻烦的是安全边界,让模型自由生成代码,等于给了它执行任意操作的权限,一旦提示词被注入或者上下文里混入了恶意指令,后果很难收拾。

第三种方案是工具调用(Function Calling),现在很多 Agent 框架在用。模型输出的不再是代码,而是一段 JSON,比如{"action": "click", "target": "login_button"}。这比生成代码安全了不少,结构化程度也高,但仍然存在几个问题:模型要完整地生成几十甚至上百个 token 的结构化文本,JSON 格式稍微出错就会导致整段解析失败;有些模型还会自作主张加字段、改参数,或者把目标元素写错。整个过程说起来是“Agent”,实际上还是让模型承担了理解、规划、表达这三重工作,而“表达”这一环恰恰是最容易出错的。

Jev Ultrafast 的做法则完全不同。它把“表达”从模型身上彻底拿掉了。工程侧提前把当前页面所有可执行的动作枚举出来,变成一道道选择题的选项,模型要做的仅仅是从 A、B、C、D 里选一个。模型不再需要组织语言,不再需要写 JSON,更不需要生成代码,它只需要比较、判断、选择。

1.2 “选择题”把错误关进笼子里

为什么选择比生成可靠?这要从自回归模型的本质说起。大模型生成文本时,每一个 token 都是从一个巨大的词表里采样出来的,哪怕概率分布已经很集中,采样次数一多,偏差就会累积。自由生成一段 200 token 的操作指令,任何一个 token 出问题,整段指令都可能变味。而选择题把搜索空间压缩到了有限集合,模型不需要“创造”答案,只需要在已有选项里做比较,这就相当于把一个开放式的填空题变成了封闭式的判断题。

选择题范式还有一个容易被忽略的好处:错误模式可控。生成式方案如果出问题,可能是在页面上执行了一段莫名其妙的操作,或者把用户名填进了密码框;而 Jev Ultrafast 里模型选错,也只是在我们预先定义好的动作里选了一个不够优的选项,系统仍然可以记录、回退、重试。调试的时候,每一步的“题目”和“答案”都能完整保存下来,就像看考试答题卡一样,模型哪一步选错了、为什么选错,一目了然。

另外,选择题方案天然适合加人工确认环节。遇到高风险的提交表单或者跳转操作,系统可以在执行前弹出一个提示,让人直接修改选项而不是去改 prompt。这在传统生成式方案里实现起来要麻烦得多。

2. Jev Ultrafast 的核心机制:动作空间、选项生成与受限输出

2.1 动作空间:页面上的可交互元素怎么变成题目

要让模型做选择题,首先得有题目。题目的来源是当前页面的 DOM 状态,我们需要一个“候选动作生成器”,把页面上的可交互元素变成一个个可执行的动作。

常见的动作类型并不复杂,无非下面这几种:

动作类型说明参数示例
click点击按钮、链接、复选框目标定位器
type在输入框或文本框输入内容定位器 + 输入文本
selectOption选择下拉菜单中的某一项定位器 + 选项值
navigate跳转到指定 URL目标地址
scroll滚动页面到某个位置方向或目标元素
wait等待页面加载或元素出现等待条件或时间

候选动作生成器会遍历页面上的button、a、input、textarea、select、[role="button"]等可交互元素,过滤掉隐藏的、禁用的、尺寸过小的元素,再根据当前任务给每个元素生成一个或多个动作。比如一个搜索框可以生成“在搜索框中输入:xxx”,一个“登录”按钮可以生成“点击按钮:登录”。

这里有个很关键的设计原则:动作参数和“选哪个动作”要解耦。也就是说,“在输入框里输入什么内容”可以由上层规划器、模板甚至另一个生成式模型来决定,但 Jev Ultrafast 的模型只需决定“要不要执行这个输入动作”。这样选择题的每个选项都足够短,模型也能更聚焦地做决策。

2.2 只输出选项的实现:分类头与 logit mask

明确了题目之后,接下来要考虑的是怎么让模型“只输出一个选项字母”。目前有两条主流的实现路径。

第一种是分类头方案。在 transformer 模型的最后一层之上加一个线性分类头,输出维度等于最大选项数,模型不再做自回归生成,而是直接把输入序列映射到一个概率分布上,取概率最高的那个选项作为答案。这个方案速度最快,但需要基于开源基座做微调,候选动作数量固定时效果最好。

第二种是 logit mask 方案,也是我实测中最通用的做法。模型依然是自回归生成,但在每一步解码时,把词表中非选项 token 的 logit 直接设为负无穷,模型想输出别的 token 也输出不出来。用 Transformers 实现的话,可以写一个自定义的 LogitsProcessor:

import torch from transformers import LogitsProcessor class ChoiceLogitsProcessor(LogitsProcessor): def __init__(self, option_token_ids): self.option_token_ids = option_token_ids def __call__(self, input_ids, scores): mask = torch.full_like(scores, float("-inf")) mask[:, self.option_token_ids] = scores[:, self.option_token_ids] return mask

调用时把选项字母 A、B、C、D 对应的 token id 传进去,模型就只能在这几个 token 之间做选择。如果用的是 llama.cpp 这类推理后端,也可以用 grammar 来表达同样的约束,写一个root ::= [A-J]的规则,把输出限制在合法选项范围内。

这种受限生成的好处是灵活。选项数量不是固定的,今天页面有 5 个可交互元素就出 5 道题,明天页面有 20 个就出 20 道题,不需要重新训练模型。唯一要注意的是模型的词表里“A”这个 token 不一定独立存在,某些字节对编码下它可能是某个词的子串片段,所以上线前要先把选项字母的 token id 映射测试一遍,否则容易踩坑。

2.3 Ultrafast 的速度红利到底从哪来

理解了实现方式,就能明白 Jev Ultrafast 为什么快。最直接的原因是输出 token 数量的大幅下降。传统生成式方案里,模型每次决策要生成一段 JSON 或者一段代码,少则几十 token,多则几百 token;而选择题方案每次只生成 1 个 token。自回归模型的推理速度受限于逐 token 解码,输出越短,延迟越低,这是最朴素也最有效的优化。

第二个原因在于候选动作生成和模型推理可以并行。浏览器端解析 DOM、组装选择题,和模型端的 prefill 计算相互独立。页面加载完成之后,选择题已经准备好了,模型只需要一次前向计算就能得出答案,几乎没有等待时间。在我实际测试的环境里,同样的任务从原来动辄 3 到 5 秒的决策时间,降到了 300 到 500 毫秒左右。

第三,因为“选答案”比“写答案”简单,So 模型能力的要求也降低了。不需要 7B 甚至 13B 的模型来做这种判断,0.5B 到 1B 的小模型在一些常见页面上的表现就已经够用。小模型再配合量化部署,显存占用低,普通开发者用消费级显卡甚至纯 CPU 推理都能跑起来。

3. 从零跑通一个 Jev Ultrafast 浏览器 Agent

3.1 模型获取与低显存部署

第一步自然是拿到模型权重。Jev 系列的具体 release 和 license 要以官方仓库为准,社区里也已经有人放了转换好的量化版本。我个人比较推荐直接用 GGUF 格式,配合 llama.cpp 或者 ollama 部署,配置成本最低。

以 llama.cpp 为例,把下载好的jev-ultrafast-q4_k_m.gguf放到本地目录,然后启动一个常驻的服务:

./llama-server -m jev-ultrafast-q4_k_m.gguf --ctx-size 4096 --n-gpu-layers 20 --port 8080

如果你是显存比较小的那类用户,--n-gpu-layers可以往下调甚至设成 0,让模型跑在 CPU 上。Q4_K_M 量化版本的大小大概只有原模型的四分之一左右,速度会慢一点,但决策场景的输入序列短,实际体验还是能接受的。上下文大小 4096 就已经够用,因为选择题的提示词通常不会太长,没必要为了显存去吃一个很大的 KV cache。

如果你更习惯 ollama,也可以直接把模型拉到本地:

ollama run <模型标识>:q4_k_m

有几点要注意:GGUF 文件下载完以后,最好比对一下仓库给的 sha256 校验值再启动,下载中断产生的残缺文件经常会报出莫名其妙的加载错误;llama.cpp 的版本也别太旧,否则可能不支持新版的 GGUF 格式。这里面的坑,我在下一章会详细说。

3.2 构建候选动作生成器

模型部署好之后,重头戏其实是浏览器这边的动作生成。我用 Playwright 来做页面控制和元素提取,核心代码骨架大概长这样:

from playwright.sync_api import sync_playwright def generate_candidates(page, max_options=20): candidates = [] locator = page.locator( "button, a, input, textarea, select, [role='button'], [role='link']" ) elements = locator.all() for el in elements: if not el.is_visible(): continue tag = el.evaluate("e => e.tagName.toLowerCase()") text = el.inner_text().strip().replace("\n", " ")[:30] if tag in ("button", "a") and text: candidates.append({"type": "click", "target": el, "label": f"点击 {text}"}) elif tag == "input": candidates.append({"type": "type", "target": el, "label": f"在输入框输入文本"}) return candidates[:max_options]

这个版本非常粗糙,真实项目里还需要处理很多细节:排除被遮罩覆盖的元素、合并文本重复的按钮、优先保留与当前任务相关的动作、给输入框加更明确的 aria-label 或者 name 标识等等。但核心思路是一致的:把不可枚举的页面状态变成一组有限的动作候选。

代码里我保留了一个max_options=20的限制,这个数字很关键。选项太少,模型可能找不到正确的下一步;选项太多,模型会开始“蒙”。20 是我实测下来一个比较稳的平衡点。

还要注意,输入框动作需要额外的文本参数。这个参数从哪里来?两种方式:如果是固定流程,直接写死在工程配置里;如果是开放式任务,就由上层规划器先生成候选文本,再塞进动作选项里。Jev Ultrafast 本身不负责生成内容,这是它的边界,也是它的优点。

3.3 把动作拼成选择题并完成推理

候选动作列表生成之后,下一步就是把它们拼成语义清晰的提示词。我常用的模板长这样:

当前页面标题:搜索结果页 当前任务:在搜索框中输入“Jev Ultrafast”并点击搜索按钮 请从以下动作中选择最合适的一个,只输出选项字母。 A. 点击 输入框:搜索框 B. 在输入框(搜索框)中输入:Jev Ultrafast C. 点击 按钮:搜索 D. 等待页面加载完成 E. 跳到页面底部

然后调用推理接口。如果用的是 llama.cpp 的 server,可以直接用 HTTP 接口,把max_tokens设为 1,temperature 设为 0,并加上 grammar 约束:

import requests resp = requests.post("http://localhost:8080/completion", json={ "prompt": prompt_text, "max_tokens": 1, "temperature": 0.0, "grammar": "root ::= [A-J]" }) choice = resp.json()["content"].strip()

拿到选项字母之后,把它映射回候选动作列表,调用对应的执行器。这里有一个执行安全的问题:点击和输入文本是低风险动作,但提交表单、跳转页面这类动作最好设置一个确认逻辑,或者至少在无头浏览器里先干跑一遍。选择题降低了模型出错的概率,但没有把出错概率降为零。

3.4 一个完整循环:自动搜索关键词并读取结果

把上面的模块串起来,一个最小可用的 Agent 循环大概长这样:

with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com") for step in range(20): candidates = generate_candidates(page) prompt = build_prompt(page, candidates) choice = choose_action(prompt, candidates) choice.execute(page) page.wait_for_load_state("networkidle")

这里有个比较重要的设计:循环一定要有最大步数限制。选择题方案在执行单步动作上很快,但如果模型在一个错误的目标上反复打转,整个流程就会变成一个死循环。我的习惯是记录最近几轮的动作,如果页面内容没有变化、动作也没有推进任务,就主动中断并请求人工介入,而不是让 Agent 一直在原地做选择题。

我拿“自动搜索 Jev Ultrafast 并读取结果标题”这个小任务做过测试,循环到第三步时模型会选择在输入框输入关键词,第四步选择点击搜索按钮,随后页面跳转,动作生成器基于新页面重新生成选择题。整个过程里模型没有输出过一句多余的话,步与步之间非常干脆,这也是选择题范式最容易感知到的优势。

4. 实测踩坑记录:模型加载、选项爆炸与选错后的自救

4.1 加载 GGUF 失败:排查链路与修复

先说一个几乎每个人都会遇到的事:模型加载失败。我最初在 llama.cpp 里启动本地服务,直接撞见了类似error loading model: ... failed to load model的报错。面对这类问题,第一反应不要是重装系统,按下面这个顺序排查就好。

第一个可能原因是文件本身损坏。模型文件动辄几个 GB,下载中断或者网盘限速导致文件不完整的情况太常见了。解决办法是到官方仓库找 sha256 校验值,用sha256sum jev-ultrafast-q4_k_m.gguf比对,不一致就重新下载。

第二个原因是内存映射失败。llama.cpp 默认用 mmap 加载模型,如果系统虚拟内存受限,或者物理内存不足,就会直接报加载失败。这种情况下可以加--no-mmap参数,让模型改用普通内存分配方式加载,虽然启动速度会慢一点,但能绕开很多环境问题。

第三个原因是 GGUF 格式和推理后端版本不兼容。量化工具一直在迭代,新版本生成的 GGUF 文件,很老版本的 llama.cpp 不一定认得。解决方法是把 llama.cpp 升级到最新版,或者反过来用官方推荐的转换脚本重新导出一次。

最后一个常见坑是--ctx-size开得太大。模型本身不大,但 KV cache 会跟着上下文窗口膨胀,显存不够的时候同样会加载失败。决策场景完全不需要 8K 甚至 32K 的上下文,老老实实设 4096 就够了。

错误现象常见原因处理办法
error loading model文件损坏或路径错误重下文件,校验 sha256
llama_model_load failed内存不足或 mmap 失败加--no-mmap,减小 ctx
GGUF version mismatch后端版本过旧升级 llama.cpp 或重新转换
显存溢出KV cache 过大减小--ctx-size,降低--n-gpu-layers

4.2 页面有五十个可点击元素,怎么做二十道题

第二个大坑是选项爆炸。第一次把一个资讯门户首页接进 Agent 时,候选动作生成器一口气吐出了 80 多个可交互元素。我把全部选项塞进提示词,模型的表现立刻变得很不稳定,经常会选一些明显“不重要”的按钮,比如页脚的版权链接。

后来我才意识到,选择题的质量不取决于选项数量,而取决于选项的可区分度。80 个相似度很高的动作堆在一起,模型根本没法判断哪个才是当前任务最需要的。解决办法其实就几步:先按元素面积、页面位置、文本长度做一轮筛选;再根据当前任务里的关键词给动作相关性打分,比如任务目标是“搜索”,那么输入框和搜索按钮的分数就应该远高于“注册”按钮;最后只保留 Top 20 的动作,同时加上一个“等待”或“跳过”选项作为兜底。

这里有个容易被忽略的经验:选择题必须允许“都不选”。如果没有这个选项,模型在找不到合适动作的时候被迫硬选一个最不坏的,反而会执行出多余的点击。加一个D. 等待页面变化或者E. 当前没有合适的动作,整个 Agent 的稳定性会明显提升。

还要注意选项顺序的稳定性。同一个按钮,这次刷新生成了第 3 个选项,下次刷新变成了第 7 个选项,模型即使选对了,执行层也可能因为映射错位而操作了错误的元素。所以在候选动作生成阶段就要给元素绑定稳定标识,优先用>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询