ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

250 行 Python 打造本地网页浏览 LLM Agent:用 TaoToken 统一 Key 打通工具调用链路

250 行 Python 打造本地网页浏览 LLM Agent:用 TaoToken 统一 Key 打通工具调用链路 1. 为什么要在本地跑一个会“看网页”的 LLM Agent很多人第一次接触 LLM Agent脑子里想的是“它能自己上网查资料然后回答我”。但真动手时卡点往往不在模型而在“怎么让模型真的读到网页内容”。模型本身不会打开浏览器它只会根据你给它的文本做推理。所以一个能浏览网页的 Agent本质上是三件事拼起来一个能抓页面的浏览器服务、一个把浏览器能力包装成工具tool的中间层、一个会决定“先搜索还是先抓取”的决策循环。我这次要搭的就是一个跑在本机的 Python LLM Agent大约 250 行核心代码能完成“收到问题 → 搜索 → 挑一个链接 → 打开页面 → 读内容 → 作答”这条链路。它适合两类人一是想搞懂 Agent 工具调用到底怎么串起来的学习者二是想在自己机器上做一个不依赖外部黑盒的网页问答小工具的人。整个过程不需要你懂前端也不需要你部署复杂框架只要会跑 Python 和 Docker 就行。关键点在于模型接入。本地跑模型当然可以但如果你手头没有合适的显卡或者想让 Agent 的决策更稳把模型 endpoint 指向一个统一的 API 通道会更省事。我这次用的是 TaoToken 的统一 Key/API 通道好处是模型名、鉴权、Base URL 都在一个地方配好Agent 代码里不用到处改。下面从目录结构开始一步步把它跑通。2. 前置准备TaoToken 统一 Key 与本地环境在写代码之前先把“模型从哪来”这件事定下来。本地 Agent 的决策质量很大程度取决于模型能不能稳定地做 tool calling。如果你用本地 Ollama 跑一个小模型有时候它会在该调用工具的时候跟你聊天而不是真的发出 tool call。所以我建议把模型通道统一到 TaoToken这样模型选择更灵活鉴权也简单。你需要准备的东西不多一个 TaoToken 账号、一个 API Key、Python 3.10、Docker。TaoToken 的 API 地址是https://taotoken.net/api这个地址会作为 Agent 里模型客户端的 Base URL。API Key 在控制台的 API Keys 页面生成生成后复制出来后面写进配置文件。这里要强调一个概念Agent 里的“模型调用”和“工具调用”是两条线。模型调用走 TaoToken 的 API 通道工具调用走你本地的 MCP server。两者通过 Agent 的决策循环连起来。所以配置上要分清模型相关的配置Base URL、Key、Model ID放一处工具相关的配置浏览器服务地址、搜索服务地址放另一处。我建议用一个config.toml集中管理避免硬编码。下面是一个最小可用的配置示例路径放在项目根目录[model] base_url https://taotoken.net/api api_key sk-你的TaoToken密钥 model_id claude-3-5-sonnet temperature 0.1 [browser] camofox_url http://localhost:9500 max_snapshot_chars 30000 settle_seconds 1.5 [search] searxng_url http://localhost:8090 [agent] max_tool_result_chars 30000 history_dir history [logging] level DEBUG注意model_id这一项它要和 TaoToken 支持的模型名一致。如果你不确定用哪个可以先在模型对话页面里试一下确认能正常返回再写进配置。temperature设低一点0.1 左右是因为 Agent 做工具决策时不需要太多“创造力”稳定比花样重要。环境变量方面我习惯把 Key 放在环境变量里配置文件里只写占位符避免误提交。但为了演示直观上面直接写了。你实际用的时候可以改成api_key ${TAOTOKEN_API_KEY}然后在启动脚本里 export。依赖清单很简单一个requirements.txt就够httpx0.27 mcp1.0 tomli2.0如果你用 Python 3.11tomllib是内置的可以去掉tomli。MCP 的 Python SDK 负责把本地工具暴露给 Agenthttpx 负责和浏览器服务、搜索服务通信。3. 可复制配置目录结构与关键代码片段先把目录结构定下来这样后面每一步都知道文件放哪local-web-agent/ ├── config.toml ├── requirements.txt ├── agent/ │ ├── __init__.py │ ├── main.py │ └── loop.py ├── servers/ │ ├── browser_server.py │ └── search_server.py ├── docker-compose.yml └── searxng/ └── settings.ymlagent/main.py是入口负责读配置、连 MCP server、启动对话循环。agent/loop.py是核心决策循环处理 tool call 和模型回复。servers/browser_server.py把浏览器能力包装成fetch和extract两个工具。servers/search_server.py包装搜索。先看docker-compose.yml它启动两个服务浏览器服务和搜索服务。services: camofox: image: camofox-browser:latest container_name: camofox ports: - 9500:9500 environment: - CAMOFOX_PORT9500 restart: unless-stopped healthcheck: test: [CMD-SHELL, curl -f http://localhost:9500/health || exit 1] interval: 30s timeout: 10s retries: 3 searxng: image: searxng/searxng:latest container_name: searxng-mcp ports: - 8090:8080 volumes: - ./searxng/settings.yml:/etc/searxng/settings.yml:ro restart: unless-stoppedsearxng/settings.yml要开启 JSON 输出否则搜索服务拿不到结构化结果use_default_settings: true search: formats: - html - json server: limiter: false secret_key: local-only-change-me然后是servers/browser_server.py的核心部分。它用 MCP 的FastMCP暴露工具内部通过 httpx 调 camofox 的 REST 接口import httpx from mcp.server.fastmcp import FastMCP mcp FastMCP(browser-server) CAMOFOX_URL http://localhost:9500 mcp.tool() def fetch(url: str) - str: 打开一个网页并返回其可访问性树快照。 if not url.startswith((http://, https://)): return fError: URL 必须以 http:// 或 https:// 开头收到 {url!r} with httpx.Client() as client: r client.post( f{CAMOFOX_URL}/tabs/open, json{url: url}, timeout60.0, ) r.raise_for_status() tab_id r.json().get(tabId) snapshot client.get( f{CAMOFOX_URL}/tabs/{tab_id}/snapshot, timeout60.0, ).text client.delete(f{CAMOFOX_URL}/tabs/{tab_id}) return snapshot[:30000] def main(): mcp.run(transportstdio)extract工具稍微复杂一点它接收一个 JSON Schema让模型从页面里抽结构化字段。核心思路是先 fetch 页面再把页面内容和 schema 一起发给模型要求只返回 JSON。这里模型调用就走 TaoToken 的通道import json import httpx from mcp.server.fastmcp import FastMCP mcp FastMCP(browser-server) TAOTOKEN_BASE https://taotoken.net/api TAOTOKEN_KEY sk-你的TaoToken密钥 MODEL_ID claude-3-5-sonnet mcp.tool() def extract(url: str, schema: dict) - str: 按 JSON Schema 从网页抽取结构化数据。 page_text fetch(url) prompt ( 你是精确的数据抽取工具。根据下面的 schema 从页面内容中抽取字段 缺失字段设为 null不要编造。只返回 JSON。\n\n fSCHEMA:\n{json.dumps(schema, ensure_asciiFalse, indent2)}\n\n fPAGE:\n{page_text} ) resp httpx.post( f{TAOTOKEN_BASE}/v1/chat/completions, headers{Authorization: fBearer {TAOTOKEN_KEY}}, json{ model: MODEL_ID, messages: [{role: user, content: prompt}], temperature: 0.1, }, timeout120.0, ) resp.raise_for_status() return resp.json()[choices][0][message][content]注意这里的 Base URL 是https://taotoken.net/api路径拼上/v1/chat/completions。Key 放在 Authorization 头里。Model ID 和配置文件里保持一致。这样模型调用和工具调用就分开了工具在本地跑模型走统一通道。4. 验证请求跑通一次端到端浏览问答配置写完先启动两个 Docker 服务docker compose up -d docker compose logs -f等 camofox 打出server startedSearXNG 打出启动日志就可以验证服务是否正常curl http://localhost:9500/health curl http://localhost:8090/search?qpythonformatjson第一个命令返回一个 JSON health 对象第二个返回搜索结果。如果第二个返回的是 HTML 而不是 JSON说明settings.yml没挂载成功检查路径。接下来启动 Agent。agent/main.py里要做的事是读config.toml用 MCP 客户端连上browser_server.py和search_server.py把工具列表转成模型能理解的格式然后进入对话循环。import asyncio import tomllib from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main(): with open(config.toml, rb) as f: cfg tomllib.load(f) browser_params StdioServerParameters( commandpython, args[servers/browser_server.py], ) search_params StdioServerParameters( commandpython, args[servers/search_server.py], ) async with stdio_client(browser_params) as (br, bw): async with ClientSession(br, bw) as browser: await browser.initialize() tools await browser.list_tools() print(可用工具:, [t.name for t in tools.tools]) async with stdio_client(search_params) as (sr, sw): async with ClientSession(sr, sw) as search: await search.initialize() await run_loop(cfg, browser, search) asyncio.run(main())run_loop是决策循环。它把用户问题发给模型模型如果返回 tool call就执行对应工具把结果塞回消息历史再问模型下一步。直到模型返回纯文本答案为止。async def run_loop(cfg, browser, search): messages [{role: system, content: SYSTEM_PROMPT}] while True: user_input input(你: ) if user_input.strip() in (exit, quit): break messages.append({role: user, content: user_input}) while True: resp call_model(cfg, messages) msg resp[choices][0][message] if msg.get(tool_calls): messages.append(msg) for call in msg[tool_calls]: name call[function][name] args json.loads(call[function][arguments]) if name browser-server_fetch: result await browser.call_tool(fetch, args) elif name search-server_search: result await search.call_tool(search, args) else: result f未知工具: {name} messages.append({ role: tool, tool_call_id: call[id], content: str(result), }) continue answer msg.get(content, ) print(Agent:, answer) messages.append({role: assistant, content: answer}) breakSYSTEM_PROMPT要写清楚工具的使用规则尤其是“搜索之后必须抓取页面不能只看摘要就回答”。小模型很容易在搜索完就停下来所以规则要硬你有两个工具 - search-server_search(query): 搜索网页返回标题、URL 和摘要。 - browser-server_fetch(url): 打开网页返回页面内容。 规则 1. 需要查资料时第一步必须调用 search。 2. 拿到搜索结果后必须对最有希望的 URL 调用 fetch不能只看摘要作答。 3. 拿到页面内容后再基于内容回答并引用 URL。跑起来后问一个模型不可能记住的问题比如“某个小网站首页现在写的是什么”。如果 Agent 先搜索、再抓取、最后给出带 URL 的回答说明链路通了。日志级别设成 DEBUG 时你能看到每一步的工具名和参数方便排查。5. 本篇常见错排查401、local proxy failed、reading choices跑这个 Agent 最容易遇到的错基本集中在模型通道和工具通道的衔接上。下面几个是我实际踩过的。第一个是401 Unauthorized。这个通常出现在模型调用那一步原因是 TaoToken 的 Key 没配对或者 Base URL 写错了。检查两点config.toml里的api_key是不是完整的 Keybase_url是不是https://taotoken.net/api。注意不要写成带 UTM 参数的地址API 调用只需要基础地址。如果 Key 是从控制台复制的确认没有多余空格。第二个是local proxy failed或连接被拒绝。这个一般出在工具通道也就是 Agent 连不上本地的 MCP server。常见原因是browser_server.py或search_server.py启动失败比如依赖没装、端口被占。先在终端单独跑python servers/browser_server.py看有没有报错。如果报ModuleNotFoundError检查requirements.txt是否装全。如果 camofox 容器没起来fetch工具会返回连接错误这时用docker compose ps确认容器状态。第三个是reading choices相关的报错比如KeyError: choices或list index out of range。这说明模型返回的结构和你预期的不一样。可能是模型名写错了TaoToken 返回了一个错误对象而不是正常的 completion。打印完整的响应体就能看到原因。另一个可能是temperature或max_tokens参数不被该模型支持去掉这些可选参数再试。第四个是工具调用死循环。模型反复调用同一个工具或者搜索完不抓取。这通常是 system prompt 不够明确或者模型本身 tool calling 能力弱。解决办法是把规则写得更硬比如“搜索后必须 fetch否则视为错误”。如果还是不行换一个 tool calling 更稳的模型。第五个是页面内容被截断导致抽取字段为 null。max_snapshot_chars设得太小页面中间的结构化内容被丢掉了。把config.toml里的max_snapshot_chars和max_tool_result_chars都调到 30000 以上再试一次。注意这两个值要匹配否则第二次截断会抵消第一次的设置。排查时有个通用技巧把日志级别设成 DEBUG看每一步的输入输出。模型调用的请求体、工具返回的内容、消息历史的增长都能在日志里看到。大部分问题看一眼日志就清楚了。6. 把模型通道固定下来继续扩展你的 Agent跑通这条链路之后你会发现 Agent 的骨架其实很轻一个决策循环、两个工具、一份配置。真正决定体验的是模型通道稳不稳、工具返回的内容干不干净。把模型 endpoint 和鉴权统一到 TaoToken 之后你换模型只需要改config.toml里的model_idAgent 代码一行不用动。这对本地实验特别友好因为你可以快速对比不同模型在 tool calling 上的表现。如果你想继续扩展有几个方向很自然。一是给浏览器服务加click工具利用页面快照里的元素引用让 Agent 能点按钮、填表单而不只是读页面。二是加extract的结构化抽取把“读页面”升级成“按字段取数据”适合做价格监控、信息汇总这类任务。三是把搜索和抓取组合成固定 pipeline减少模型决策的不确定性。API Key 和接入文档在控制台和文档页都能找到模型对话页面可以用来快速验证某个模型是否支持 tool calling。如果你打算长期跑编码类或 Agent 类任务Coding Plan 会更省心额度和通道都更稳定。先把今天这个 250 行的骨架跑起来再按自己的需求往上加工具比一上来就上重框架要踏实得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进