
OpenAI 智能体自己跑了。当终端日志里连续滚出十几条澳洲政府网站的访问记录时我第一反应不是兴奋而是赶紧把成本告警拉到最高。这个局面源于我临时搭的一个实验让 GPT-4o 驱动一个浏览器自动化工具去公开网站上完成一趟自主信息巡检。之前我对智能体的认知还停留在“调接口、回答问题”这次亲眼看着它自己选链接、自己开页面、自己判断信息是否够用我才意识到agent 真正跑起来以后跟普通聊天完全是两个物种。这篇文章把整套设计、运行过程、踩过的坑和护栏思路全部摊开希望能给正在折腾 OpenAI 智能体开发的朋友一点真实参考。适应范围先说清楚适合有 Python 基础、想用 OpenAI API 搭建信息收集类智能体的人。你不需要懂强化学习但最好知道怎么用 pip 和跑脚本。1. 为什么非要做一个会自己跑的 OpenAI 智能体1.1 我的真实痛点人工翻网页太费时间我手头有一个长期需求定期跟踪公共服务信息。这类站点页面多、更新频繁前天写的申请流程下周可能就换成新表格。过去我的做法是把所有相关页面加入收藏夹每周人工过一遍看到变动就截图记录。任务倒不复杂但非常耗神一个页面 5 分钟看不完还要来回对比版本。真实的痛点不是“找不到信息”而是“信息太散无法高效筛出我要的那部分”。比如我明确知道某个公开页上有“当前申请费用”但那个页面还混着申请条件、处理时长、材料清单、常见问题。我需要的只是费用那一小块而页面数据是写在一起的。人工做要滚动、复制、粘贴、整理用代码做要针对不同页面写不同解析逻辑。写爬虫的成本比手工还高因为那几个网站的 HTML 结构从来不会让你舒服。所以我想要的是一个能听自然语言指令、自己决定访问哪个页面、自己判断“这个页面有没有我要的东西”的智能体。我不写具体选择逻辑只描述目标剩下的交给模型。1.2 为什么把试验场选在澳洲政府网站把试验对象放在澳洲政府网站并不是因为它有多特殊反而是因为它的公开页面非常适合做智能体试验。第一信息结构化程度高。官方公开网站的页面普遍有清晰的标题层级、小标题、列表和表格模型读正文时很容易定位“费用”“申请条件”“办理周期”这些小节。第二更新频率够真实。这类站点经常调整措辞、更新价格、调整流程正好可以检验智能体是不是真的读到了“当前版本”而不是靠记忆编答案。第三大部分页面是公开信息不需要登录、不需要个人身份。我用“试验场”这个词指的是互联网上任意人都能访问的公开资料页而不是某个内部系统。智能体在这里跑不会有越权问题同时又能碰到真实网站该有的动态加载、导航菜单、跳转链接等麻烦。第四如果你用普通爬虫去抓很容易被结构变化搞崩但用模型驱动就不一样。它不像传统爬虫那样依赖固定的 XPath 或 CSS 选择器而是像一个人一样“阅读”页面所以抗页面结构变化的能力更强。这一点是我最想验证的。1.3 核心思路让模型“看见页面再做决定”传统自动化脚本的逻辑是“执行固定步骤”打开首页点第二个链接向下滚动提取第五个表格。它非常精确但没有任何应变能力页面稍微改一下脚本就废了。智能体的逻辑不一样。它的每一步由观察结果决定。打开一个页面把正文内容喂给模型模型会回答“这个页面提到了材料清单但我还需要费用字段。”然后它调用工具打开费用子页面再读一遍再判断。整个过程是循环的看到页面内容 - 决定下一步动作 - 执行浏览器工具 - 拿到新页面内容 - 再决定。这就是常说的 ReAct 循环。拿人打比方你不是给扫描仪下达“按坐标扫描”的命令而是给助理说“去查一下护照办理流程缺什么就顺藤摸瓜查什么”。助理会自己翻页、自己判断、自己决定什么时候够了。关键是这一步不需要重新训练模型OpenAI 的 function calling 功能天然支持这种交互。你只需要把工具列表传给接口模型在对话过程中自主发起工具调用。我们要做的只是维护循环、控制成本和防止失控。2. 智能体自动运行的四个核心部件2.1 大脑ReAct 循环与 function calling智能体的“大脑”是一个通过 OpenAI API 反复调用的对话循环。每次循环我把当前任务说明、历史步骤、最新页面内容一起发给模型模型可能返回普通文本最终回答也可能返回一个工具调用请求例如“打开这个 URL”。代码层面是这样的最小骨架import json import os from openai import OpenAI client OpenAI(api_keyos.environ[OPENAI_API_KEY]) messages [ {role: system, content: system_prompt}, {role: user, content: user_task}, ] # 单步推理模型决定是继续调用工具还是输出最终结果 resp client.chat.completions.create( modelgpt-4o, messagesmessages, toolstools, tool_choiceauto, temperature0.2, ) msg resp.choices[0].message if msg.tool_calls: for call in msg.tool_calls: messages.append({ role: assistant, tool_calls: [call.model_dump()], }) # 执行工具把结果追加回 messages result run_tool(call.function.name, json.loads(call.function.arguments)) messages.append({ role: tool, tool_call_id: call.id, content: result, }) # 继续下一轮 else: final_answer msg.content这里的tools是一个函数声明列表用来告诉模型“你可以调用这些工具”。模型不会真的执行函数它只负责输出一个结构化的调用请求真正的执行发生在我们的代码里。拿到结果后再塞回上下文模型就能基于新信息继续推理。为什么要走 function calling而不是直接让模型输出 JSON因为 function calling 的稳定性高得多。模型输出 JSON 容易漏字段、加语气词、把布尔值写成字符串而 function calling 是 OpenAI 在底层做过对齐的结构化输出还自带了类型校验省心一大截。另外tool_choiceauto让模型自己决定是否调用工具这正是“自主”的来源。2.2 眼和手浏览器自动化层模型只会做决策真正“打开页面、读取文本”的工作要交给浏览器自动化层。我选的是 Playwright原因很简单现在很多公共服务网站都是单页应用或动态渲染直接用requests拿不到正文只有真正启动一个无头浏览器让 JavaScript 跑完才能拿到完整的文本内容。最小实现长这样from playwright.sync_api import sync_playwright def fetch_body_text(url: str, timeout_ms: int 30000) - str: with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(url, timeouttimeout_ms, wait_untildomcontentloaded) page.wait_for_timeout(800) # 给动态渲染留一点缓冲 text page.inner_text(body) browser.close() return clean_text(text)clean_text是我的一个过滤函数把过长空白、导航栏重复内容、cookie 弹窗文字去掉只保留主干正文。这一步很重要因为模型每轮看到的 context 有限你不能把 10 万字符的页面原文全塞进去。用无头浏览器开页面当然比重发 HTTP 请求慢和重但对智能体来说这是必须付出的代价。你要的不是“拿到 HTML”而是“像人一样看到渲染后的页面”。2.3 记忆短期上下文与长期缓存智能体的“记忆”分两层。短期记忆就是 OpenAI 接口里的messages数组它记录当前任务从开始到现在所有关键信息。长期记忆则是我在 Python 代码里维护的缓存和状态。最容易犯的错误是无限累积上下文。每执行一次工具调用页面正文就要占用几百到几千 token十几轮下来上下文可能突破几万 token成本上升不说模型还会因为信息太杂而“忘掉”最初的任务。我的处理办法有三个第一每次工具结果都要截断。页面正文最多保留 3000 到 4000 个字符超出部分直接截断并在内容末尾标注“(已截断)”。这样既保留核心信息又不会撑爆上下文。第二维护一个已访问 URL 集合。已经看过的页面不要再重复打开。如果真的需要重新看同一页直接说“你之前已经看过 xx 页它提到过 yy”让模型基于历史记忆做判断。这能显著减少无效调用。第三做内容摘要缓存。同一个页面如果短时间内被重复访问我不重新启动浏览器而是直接把上次的摘要结果返回。2.4 闸门停止条件与安全护栏让智能体自主跑起来不意味着让它无限跑下去。我把“护栏”看作整个系统里优先级最高的组件而不是事后的点缀。没有护栏一个简单的查资料任务也能因为模型一时思路跑偏变成爬虫马拉松。我在所有任务里都强制设定这几个上限参数我的默认值说明MAX_STEPS15单任务最多执行 15 轮推理/工具调用MAX_NEW_PAGES8最多访问 8 个不同 URLPAGE_TIMEOUT30 秒单页面加载超时MAX_CONTEXT_PER_PAGE3000 字符单次工具结果上限并发数1默认不并发避免触发访问频率限制这些上限看起来粗暴但非常有效。智能体不是人类不会累如果没有边界它会在同一个死循环里一直打转还会真实地消耗你的 API 额度。先设定停止条件再谈执行能力这是我后来所有智能体项目的第一原则。3. 动手搭建让智能体在澳洲政府网站完成一次自主巡检3.1 准备 OpenAI API、Python 环境和浏览器驱动环境搭建没有太多花样照着下面这几步走python -m venv agent_env source agent_env/bin/activate pip install openai playwright playwright install chromium export OPENAI_API_KEYsk-你的密钥这里要注意两点。第一API Key 一定要通过环境变量传入千万不要硬编码进代码里再传到 GitHub。第二playwright install chromium必须执行否则后面启动无头浏览器会报错。装完以后我就用最简单的接口确认联通再继续下一步。如果是第一次使用 OpenAI Python SDK我建议先跑一段最小的对话测试确认网络环境和密钥没问题再做工具调用。这个环节跳过的话后面排错会非常痛苦因为你分不清是接口问题还是智能体逻辑问题。3.2 让智能体学会“读取页面的公开信息”要让模型能自主使用浏览器核心是定义好tools参数。我定义的第一个工具是fetch_pagetools [ { type: function, function: { name: fetch_page, description: 打开一个公开网页并返回正文文本。只适用于公开无需登录的页面。, parameters: { type: object, properties: { url: { type: string, description: 要访问的完整 URL, } }, required: [url], }, }, }, ]工具描述里的“公开无需登录”不是废话。模型靠 description 理解这个工具适合什么场景如果我在这里写“读取任何页面”它可能会尝试打开登录页、乱填表单然后拿一堆报错信息回来。把边界写在描述里模型在决策时就会更有分寸。我还会加一个final_answer工具让模型在收集到足够信息时主动结束任务并输出结构化结果。否则模型可能会在消息里直接写答案而不触发结束逻辑程序还要额外判断“什么时候算结束”。{ type: function, function: { name: final_answer, description: 向用户输出最终整理结果并结束任务。只有当你认为已经从页面中收集到足够信息时才能调用。, parameters: { type: object, properties: { result: { type: object, description: 包含最终结论的 JSON 对象, additionalProperties: True, } }, required: [result], }, }, }有了这两个工具模型就能在“打开页面继续查”和“我查完了输出答案”之间做决定这就是自主行为的基本形态。3.3 给它一个任务梳理一项政策的最新要求工具准备好了以后我给了智能体一个完全开放的任务在澳洲政府公开网站上梳理一个公共服务项目的基本要求包括申请条件、材料清单和办理周期。系统提示词的大致结构是你是一个信息查询智能体任务是查询澳洲政府公开网站上关于某项公共服务的办理要求。 规则 1. 只能访问公开、无需登录的页面。 2. 必须基于页面实际内容回答禁止凭空编造。 3. 如果某个页面没有你需要的字段请继续进入相关子页面查找。 4. 不要重复打开同一个 URL。 5. 如果页面提示需要登录、遇到验证码或是 404请记录该状态然后选择其他路径继续。 6. 当所有需要的信息都已找到调用 final_answer 工具输出 JSON。注意第 3 条和第 5 条。第 3 条给了模型“继续行动”的权限它不会因为第一页没有信息就停止第 5 条给了一个失败分支处理方案它不会因为一个页面打不开就崩溃。用户消息里则只写任务目标请查询并梳理该公共服务的基本办理要求输出申请条件、所需材料、办理周期、费用。这里的技巧是系统提示词描述规则用户消息描述目标。不要让两个信息混在一起否则模型容易把规则当成任务内容。3.4 运行结果它是怎么“自己跑”起来的我盯着日志看完了整场运行。最开始模型从给定的入口页面开始读到导航菜单里有“服务介绍”链接于是调用fetch_page打开。它发现这个页面只写了申请条件没有费用信息于是回到上一页又打开“费用说明”子页。中间出现过一次有趣的情况它打开的子页面上写的是英文内容但任务要求是中文输出。模型没有抱怨而是直接把英文页面里对应的字段翻译表达成中文再放进最终答案。这个能力普通爬虫完全不具备。最终运行日志大概长这样step 1 fetch_page https://example.gov.au/service/index step 3 fetch_page https://example.gov.au/service/condition step 5 fetch_page https://example.gov.au/service/fees step 6 final_answer总共 6 步就完成了任务没有多余访问。这次运行的全部输入 token 大约在 3 万左右按当时 gpt-4o 的价格成本不到一块钱人民币。仅从结果看我过去要花半小时人工干的事它两分钟做完而且每一轮决策都有迹可循。但先别急着高兴真正跑起来之后问题非常多。下面这部分是全文最值得看的。4. 运行中更容易踩的坑含紧急止损方案4.1 页面结构变化导致的目标丢失我第一次让智能体跑“查询费用”这个任务时翻车得非常彻底。政府网站的导航菜单在任务中途改版了原页面里的“费用说明”链接被移到了新的菜单层级下面。传统爬虫到这里已经废了因为 XPath 定位失败。智能体虽然没有 XPath但它读到的页面文本里已经没有完整的导航栏目于是它不知道自己下一步该点什么开始乱跳。我处理这个问题的方式是给智能体加一个“站点搜索”工具而不是让它从当前页面里硬找链接。具体做法是把站内搜索接口暴露成工具模型遇到找不到导航时直接调搜索拿搜索结果里的链接继续走。这个设计比指望一个页面把所有链接都展示出来要可靠得多。另外不要过度依赖页面的视觉结构。模型读的是纯文本和语义页面里“相关链接”区块无论放在左侧还是右侧对模型来说都只是文字序列。只要正文里有足够语义它就能找到线索。4.2 费用失控与循环陷阱自主智能体最贵的不是正常完成任务而是在同一个地方反复打转。我遇到过最典型的场景是模型访问一个页面页面内容很长截断后正好把关键字段截掉了。模型发现没找到目标就再次打开同一个 URL还是截断还是没找到于是又打开……反复十几次费用涨得飞快但它其实只是在一个截断产生的死循环里挣扎。这种问题的解法有两层。第一层是我上面说过的已访问 URL 去重同一个 URL 重复请求直接返回“已访问过不许再来”。第二层是给工具调用加上失败记忆如果同一个工具、同一个参数已经调用过两次且没有产生新信息下次就直接警告模型“这个方向走不通不要再试”。我把这两种逻辑都塞进了run_tool函数。不要指望模型自己记得之前失败过它可能确实记得但 context 太长的时候也会忽略。代码层面的硬去重是必需品不是可选项。4.3 验证码、登录墙与非预期拦截即使是公开网站偶尔也会出现验证码或者临时访问限制尤其是在你短时间频繁打开页面的时候。我的原则非常明确遇到验证码或登录墙直接放弃这条路径不尝试通过任何方式绕过。智能体对“被拦截”这件事没有羞耻心它只会把页面内容原样返回而验证码页面的文本通常很短模型很容易误判成“页面内容不足继续刷新”。我的处理方式是让浏览器层识别验证码特征比如页面标题包含 verification 字样或者出现 challenge 关键词一旦识别到就直接抛出一个 blocked 状态并让模型结束当前分支。访问频率也要主动降下来。我默认给两次页面访问之间加了 1.5 秒的延时并发数调成 1。公共服务网站不是你压测的对象温和访问对大家都好。4.4 日志与可观测性设计让智能体自主跑最重要的不是跑得快而是“跑完之后讲得清”。没有日志的 agent一旦出了问题你只能看着账单流泪完全不知道钱花在哪儿了。我用的日志方案是 JSON Lines每行记录一个事件字段包括时间、步骤序号、工具名、URL、页面摘要、输入 token、输出 token、当前累计成本。一条典型记录长这样{ts:2026-01-18T12:00:03Z,step:4,tool:fetch_page,url:https://example.gov.au/service/fees,summary:返回费用页正文 3200 字符,input_tokens:12800,output_tokens:210,accumulated_cost_usd:0.0042}有了这些记录我可以在智能体跑完以后回放整个决策链哪一步读到了什么、为什么选择这个链接、什么时候开始出现重复行为。这就像飞机上的黑匣子。没有它任何优化都无从谈起。5. 合规红线与经验沉淀5.1 哪些实验可以做哪些不能碰智能体能够访问网页不代表什么都可以做。我给自己划的红线很简单列在下面可以做访问明确的公开信息页低频、礼貌访问遵守站点 robots 提示只在任务必要范围内读取最小信息量。不能碰采集用户个人信息不受限制地批量下载站点内容尝试访问需要登录或验证的后台区域用任何方式规避站点访问限制。这套红线看上去基础但实际运行时很容易越界。比如模型看到一个“搜索接口”就想去调这个接口如果返回了不该返回的数据风险就在一瞬间发生。所以我有个硬性要求工具定义里就写清楚“只允许访问公开页面”并且在代码层面对 URL 做白名单前缀检查。5.2 通用护栏任务白名单、速率、审计关于白名单最简单的实现方式就是用前缀匹配ALLOWED_PREFIXES (https://publicservice.gov.au/,) def is_url_allowed(url: str) - bool: return url.startswith(ALLOWED_PREFIXES)在fetch_page被调用前先跑这个函数不满足就直接返回 error。白名单不是为了让模型更聪明而是让它的“自主权”限定在一个可控范围内。黑名单我也试过效果远不如白名单因为模型的创造力远远超过你设黑名单的速度。速率控制放在浏览器层限制全局每分钟最多访问 12 个页面单任务最多 8 个不同 URL。审计日志放得越细越好我甚至会把每一步的 messages 长度和 token 估算都记录下来。这些数据在你后续优化 prompt 和工具设计时会非常有用。5.3 我总结的智能体落地原则跑了这么多轮被坑过、烧过钱之后我自己的体会是智能体能不能在业务里真正落地从来不是看它有多聪明而是看它多听话地知道自己什么时候该停。我总结了几条原则现在基本成了我做所有 agent 项目的默认约束先写停止条件再写执行能力。没有停止条件的智能体永远不要放进生产环境。把失败当成正常路径来设计。页面打不开、字段缺失、验证码拦截这些都会发生不要假设模型每次都顺利。每个任务做小切口。不要让它同时查 5 个领域一次只查一个效果和稳定性都会有提升。日志是审计和排错的基础这碗饭的钱不能省。最后再分享一个小技巧我习惯给每次工具调用都生成一个request_id并关联到父任务 ID日志里一直带着这个串。这样一旦出问题我能用一条请求链把整个决策过程完整串起来不用靠模糊的时间戳去猜顺序。对于任何想要“自己跑”的智能体这个习惯都值得一开始就建立。