Skip to main content
单靠模型本身,无法告诉你 Hacker News 首页此刻有什么。要做到这一点,它需要工具,而工具需要有人构建和维护。Apify 已经做了这件事:它托管着数千个 Actor,用来抓取网站、爬取文档、拉取结构化数据,并通过 Model Context Protocol 把它们暴露出来。 这个组合和 Venice 非常契合。Venice 提供兼容 OpenAI 的函数调用且不留存数据,Apify 提供工具,MCP 则是两者之间的通信格式。你不用为每个网站写一个爬虫——连接一次,让模型自己挑 Actor。 在本教程中,我们将用 Python 构建一个恰好做这件事的终端智能体。完成之后,你会得到一个 CLI:它在运行时发现一个支持函数调用的 Venice 模型,通过 MCP 加载 Apify 工具目录,把回答流式输出到你的终端,并在花钱运行 Actor 之前先征求你的同意。 想看完整代码实现?请查看 GitHub 仓库 在继续之前,你需要一个 Venice API key:

我们要构建什么

参考实现是一个小型 Python 包,每个模块只负责一件事: 一个问题在其中的流转过程是这样的:
  1. 除非你固定了模型,否则向 Venice 询问当前的函数调用模型。
  2. 连接 Apify MCP 服务器并列出它的工具。
  3. 把这些 MCP 工具改写成兼容 OpenAI 的函数定义。
  4. 把问题连同工具列表一起发送。
  5. 如果模型返回 tool_calls,就在 Apify 上执行它们,并把结果作为 tool 消息追加进去。
  6. 重复,直到模型给出文本回答而不是工具调用。
第 4 到第 6 步就是整个智能体。其余一切都是为了让这三步更安全、更好用。
这个智能体可能会消耗你账户里的 Apify 算力。如果你只需要搜索和文档工具,就先不要设置 APIFY_TOKEN;在你真的打算运行 Actor 之前,也不要加 --yes

搭建项目

参考项目使用 Python 3.12+ 和 uv 创建一个新项目:
安装依赖:
这里的 httpx2httpx 的 2.x 系列,openaimcp 都已经依赖它。直接安装它可以避免同一个环境里出现两个 HTTP 客户端。 然后创建一个 .env 文件:
VENICE_API_KEY 来自 Venice API 设置APIFY_TOKEN 来自 Apify Console,而且是可选的——稍后我们会讲不设置它时你能得到什么。

加载配置

配置放在最前面,因为其他每个模块都会把它作为参数接收。我们用 pydantic-settings,让环境变量、.env 和 CLI 参数都汇入同一个经过校验的对象。 src/venice_terminal_agent/config.py 中,一个 Settings(BaseSettings) 类承载关键字段:
其中有两处体现的是设计决策,而不只是默认值。venice_modelNone 而不是某个模型 ID,下一节会回到这一点。而 max_roundsmax_tool_result_chars 是防止智能体失控的两道限制:前者限制一个问题最多能进行多少轮工具调用,后者限制抓取到的页面有多少内容会被送回上下文。 这个模块里有意思的函数是 URL 构建器:
托管版 Apify MCP 服务器接受一个 tools 查询参数,决定它对外公布哪些工具。没有 APIFY_TOKEN 时,我们请求那四个无需认证即可使用的匿名工具——Actor 搜索、Actor 详情、文档搜索和文档获取。这意味着任何人克隆这个项目、只添加一个 Venice key,也能得到一个可以调研 Apify Actor 的可用智能体。只是无法运行 Actor。

与 Venice 通信

Venice 兼容 OpenAI,所以 chat completions 可以用 OpenAI SDK,模型发现调用则用普通的 httpx 创建 src/venice_terminal_agent/venice.py
给同一个 API 配两个客户端看起来冗余,但它们各司其职。AsyncOpenAI 免费提供流式辅助方法和带类型的 tool_calls。原生 httpx 客户端则用于 OpenAI SDK 不认识的 Venice endpoint,在这个项目里就是 /models/traits 聊天超时远长于发现调用的超时是有意为之。一个会触发网页爬取的问题,合理地花上几分钟并不奇怪。

在运行时发现模型

Venice 的模型 ID 会轮换,硬编码某一个是让智能体在一个月内坏掉的最快方式。GET /models/traits 把稳定的 trait 名称映射到当前担任该角色的模型上,所以我们请求 function_calling_default,而不是点名某个模型:
这里的优先级很重要:显式的 --model 参数最优先,其次是环境变量里的 VENICE_MODEL,最后才是 trait 查询。因此默认路径完全不需要配置,而当你想对比两个模型的行为时,仍然可以固定一个。
并非每个文本模型都支持函数调用。请求 function_calling_default 这个 trait 意味着你拿到的一定支持,而无需自己维护一份列表。底层 ID 的变更频率参见弃用说明

流式补全

现在加入补全调用:
我们采用流式,让用户能看到文本随生成逐步出现,但之后仍然需要组装好的完整消息——工具调用是分散在许多 chunk 里的碎片,手工重组非常繁琐。SDK 的 stream() 上下文管理器两头都管:content.delta 事件驱动终端输出,get_final_completion() 则交回一条 tool_calls 已经拼接完毕的完整消息。 请求本身由一个独立函数构建,这样便于测试:
extra_body 是 OpenAI SDK 透传它未建模字段的方式,venice_parameters 就放在这里。把 include_venice_system_prompt 设为 false 可以让 Venice 的默认助手 prompt 不进入对话,这样我们自己的系统 prompt 就是模型收到的唯一指令。对一个有严格工具规则的智能体来说,这正是你想要的。 只有在至少有一个工具时才附上 toolstool_choice。发送一个空的 tools 数组,只会平白让模型困惑。 这个模块还有一个 format_http_error() 辅助函数,把 APIStatusErrorhttpx2.HTTPStatusError 变成一行带状态码和响应 body 的字符串。智能体最常在 API 边界上失败,那里有一条可读的信息,能省下大量猜测。

把 MCP 工具转换成 Venice 工具

MCP 工具和 OpenAI 风格的函数工具用不同的形态描述同一样东西。两者都有名称、描述和一份参数 JSON Schema。转换基本上是机械工作,只有一个坑:Apify 工具名里包含函数名不允许的字符。一个 Actor 工具可能叫 apify/rag-web-browser,那个斜杠是非法的。 所以我们在出口处清洗名称,并保留一份映射,以便在入口处再还原回去。 src/venice_terminal_agent/tools.py 中,ToolCatalog 负责转换并持有这份映射:
三个小辅助函数干着不起眼的活。sanitize_tool_name() 把非法字符替换成连字符,为以数字开头的名称加前缀,并截断到 64 个字符。unique_name() 则在截断导致两个 Actor 撞名时追加数字后缀——这能帮你避开一个真正令人困惑的 bug:模型调用的是一个 Actor,实际跑的却是另一个。tool_input_schema() 负责应付 MCP 服务器返回 dict、Pydantic 模型或干脆什么都不返回的情况。

把结果格式化回上下文

工具结果会直接进入对话,所以它们必须是字符串,而且必须有大小限制。抓取一个文档站,很容易返回超出上下文窗口容量的文本。 format_tool_result() 在服务器提供 structured_content 时优先使用它,否则把内容块拍平成文本,并处理那些不是 TextContent 的块。它以最要紧的两行收尾:
这条截断提示是写给模型看的,不是写给你的。告诉它内容被截断了,并建议使用 filter、limit 或 offset,通常就足以让它发起一次范围更窄的第二次调用,而不是假设自己已经看到了全部。 错误会被包装成 {"error": "..."} 而不是抛出。一次失败的工具调用是模型可以据此行动的信息——它可以换一个 Actor 或修正参数——而前提是失败以普通工具结果的形式到达它那里。

标记会花钱的工具

Apify 工具可以干净地分成两组:读取元数据和文档的,以及启动算力的。我们希望第二组需要确认,所以把第一组放进允许列表:
用允许列表而不是阻止列表,是这里的关键选择。Apify 一直在新增工具和 Actor,而智能体没见过的任何东西都默认先询问。要是反过来,每个新 Actor 都会被自动批准。

通过 MCP 连接 Apify

Apify 提供两条接入路径。托管服务器 https://mcp.apify.com 使用 Streamable HTTP,而 @apify/actors-mcp-server 通过 npx 在本地以 stdio 运行。我们两者都支持,因为它们适合不同的场景:托管版不需要 Node.js,stdio 版则让连接留在你自己的机器上。 src/venice_terminal_agent/apify_mcp.py 中,ApifyMcp 类封装已连接的会话。它的 call_tool() 就是把清洗过的名称翻译回去的地方——Venice 发送 apify-rag-web-browser,Apify 收到 apify/rag-web-browser
构建目录需要对 client.list_tools() 做游标循环,因为一个能访问许多 Actor 的 token 会产生分页列表。

掌管传输层

MCP 连接是一个长生命周期的异步资源,它下面的 HTTP 客户端也是。ApifyMcpSession 这个异步上下文管理器用一个 AsyncExitStack 同时持有两者,根据设置选择传输方式,并加载工具目录。值得照抄的细节是清理逻辑:
那个 except BaseException 比看上去更重要。如果传输已经建立之后列出工具失败了,没有它的话,智能体每次启动失败都会泄漏一个子进程或一个打开的 socket。 两种传输方式如下:
注意 HTTP 传输上 300 秒的读取超时。Actor 运行很慢,默认的 30 秒超时会掐断完全健康的爬取。同样注意 stdio 子进程的环境里只有 APIFY_TOKEN,而不是你的整个 shell 环境——包括你的 Venice key 在内。

执行工具调用

这个模块的最后一块是 execute_venice_tool_call(),它把一次 Venice 工具调用变成字符串结果。它把两类失败——无法解析的参数和失败的 Apify 调用——都包装成 {"error": "..."} 而不是抛出:
格式错误的 JSON 参数是会出现的。出现时,把 {"error": "invalid arguments: ..."} 交给模型,下一轮就能得到修正过的调用;而抛出异常会杀死会话、丢掉对话。

运行工具循环

现在轮到智能体本身了,位于 src/venice_terminal_agent/agent.py。先从系统 prompt 开始:
那里的每条规则都对应一个我们想避免的具体失败。“调用不熟悉的 Actor 之前优先使用 search-actorsfetch-actor-details”之所以存在,是因为一个瞎猜 Actor 输入 schema 的模型会浪费一次付费运行。关于被拒绝工具的那行之所以存在,是因为否则模型会把拒绝当成暂时性错误,并立刻重试。 Agent 类接收两个客户端、一个模型、一个轮数上限和三个回调:
正是这些回调让智能体独立于终端。on_tool 报告工具调用,on_text 接收流式 token,approve_tool 回答确认问题。把它们换掉,同一个智能体就能跑在 Web 应用或聊天机器人后面。 循环如下:
这就是整个智能体:调用模型,如果它请求了工具,就运行工具再调用一次。 start 索引和异常处理里的 del 值得细看。如果一个问题中途失败——网络错误、Ctrl+C、轮数用尽——对话里会留下一个请求了工具却从未得到结果的 assistant 回合。Venice 会拒绝下一次请求,因为 tool_calls 回合后面必须跟着对应的 tool 消息。回滚到问题开始的位置,意味着失败的问题不留任何痕迹,REPL 依然可用。

回传 assistant 回合

接下来这个函数很小,却很容易写错:
显而易见的实现是 message.model_dump(exclude_none=True),而它会破坏工具调用。工具调用回合的 contentnull,丢掉这个键会改变你回传消息的形状。exclude_unset=True 才是你要的版本:它保留模型确实设置了的 null 值,并省略它从未发送过的字段。 它还保留了 OpenAI schema 不认识的字段。推理模型会返回 reasoning_contentreasoning_details,这些字段需要在往返中存活下来,模型才能在多轮工具调用之间保持自己的思维链。

执行调用并设卡

模型可以在一个回合里请求多个工具,没理由一个一个地跑。但我们确实希望按顺序征求批准,因为交错的确认提示会让人没法读。所以先规划,再并发执行:
被拒绝的工具仍然会得到一条 tool 消息。每个 tool_call_id 都需要回复,漏掉一个就会让对话格式失效。只不过这条回复的内容恰好是解释用户说了不。 批准检查本身会查询两种名称,因为模型用的是清洗过的名称,而我们的允许列表用的是 MCP 名称:

加上 CLI

src/venice_terminal_agent/cli.py 里的 CLI 就是 Typer 加一个 REPL,是项目里最无趣的文件——但其中三个细节值得照抄。 第一是 Typer 选项被标注为可选且默认为 None,这样设置加载器就能区分”没有传”和”传了一个假值”:
正是这些 None 默认值让交接给 load_settings() 变得安全,因为你没用过的参数永远不会覆盖环境变量:
yes or None 是同一思路在布尔参数上的应用:--yes 会设置它,省略它则传 None 而不是 False,这样环境变量里的 AUTO_APPROVE_TOOLS 才能保留下来。 第二是启动顺序。先解析模型,再打开 MCP 会话,然后构建智能体——并在 finally 里关闭 Venice 客户端,因为无论问题成功与否,MCP 会话和 HTTP 客户端都需要收尾:
第三是批准器,它是智能体中唯一纯粹为了保护你的 Apify 账单而存在的部分:
isatty() 检查是大家最容易忘的部分。在 cron 或 CI 里运行智能体时没有人来回答提示,所以一个幼稚的实现要么永远挂起,要么悄悄批准。这里它会拒绝、说明原因,并让模型继续使用只读工具。default=False 意味着误按一次回车不会启动付费运行,而中断提示会被算作拒绝。 这个模块的其余部分都是寻常的终端工作,知道里面有什么就够了,不必细读:一个 prompt_toolkit REPL 循环、一个处理斜杠命令的 _handle_command() 查找、一个装着 Rich 辅助函数的 render.py,以及一个把缺失的 VENICE_API_KEY 变成可读信息而不是 Pydantic 堆栈的 _settings_error()。其中三处承载着设计决策: 斜杠命令有 /help/clear/quit,还有两个物有所值的:/tools 打印已加载的目录,通常能解释智能体为什么挑了一个奇怪的工具;/reload 则能拾取你在会话中途添加到 Apify 账户里的 Actor。 最后,在 pyproject.toml 里接好入口点,让 uv run venice-agent 可以工作:

运行智能体

启动交互式会话:
或者问一个问题就退出:
你会看到横幅,然后是逐个发生的工具调用:
先看横幅上的 Apify 那一行。如果它写着 “anonymous Apify tools only”,说明你的 APIFY_TOKEN 没有加载成功——现在就发现,可比困惑十分钟为什么智能体拒绝运行 Actor 之后才发现好得多。 当你清楚自己需要什么时,收窄工具目录:
更小的目录不只是为了省钱。可选工具更少、更相关时,模型通常挑得更准,而 --tools 是收窄选择范围最省事的方式。 在本地运行 MCP 服务器,而不是使用托管版:
这种方式需要 PATH 里有 Node.js,因为它通过 npx 启动 @apify/actors-mcp-server,而且需要 APIFY_TOKEN——本地服务器没有匿名模式。 而当你确实想要无人值守地运行 Actor 时:

测试各个部件

这里所有有意思的逻辑都不需要网络。一个从预设回复列表里弹出消息的 FakeVenice,加上一个用 SimpleNamespace 工具构建真实 ToolCatalogFakeApify,就足以驱动一整轮工具调用:
对角色序列做断言是智能体代码的好习惯。它能抓住那些格式失效的对话 bug——否则这些 bug 会一直隐形,直到 Venice 返回 400。 还有三个测试值得写,它们都以同样的方式对 agent.messages 做断言。其一,失败的运行会把历史回滚到只剩 ["system"],无论失败原因是 Venice 报错还是耗尽了 max_rounds。其二,即便批准器返回 False,只读工具仍然会运行。其三,被拒绝的付费工具会留下一条包含 declinedtool 消息,而 apify.calls 保持为空。 运行测试套件:

隐私与成本须知

一个会触达两个 API 的智能体,值得把数据流向讲清楚: Venice 的零数据留存覆盖的是模型这一侧。它不覆盖 Apify,而一次 Actor 运行会把结果写进你的 Apify 账户。如果某个任务在意这一点,就不带 APIFY_TOKEN 运行,只用匿名的发现类工具。 在成本上,三个习惯就能帮上大忙:
  • 开发期间不要加 --yes。观察模型想运行哪些 Actor 本身就很有信息量。
  • --tools 把目录收窄到你真正审阅过的 Actor。
  • max_rounds 保持克制。十二轮对研究类任务绰绰有余,更低的上限能在模型陷入循环时控制损失。

扩展这个示例

这个循环是地基。它跑通之后,值得尝试的方向包括:
  • 加第二个 MCP 服务器。Agent 里没有任何 Apify 专属的东西,所以合并多个服务器的目录主要就是给工具名加命名空间。
  • 把对话持久化到 SQLite,这样你可以恢复会话,或审计某个 Actor 返回了什么。
  • 加上按工具计的预算,跟踪 Actor 运行并在上限处停下,而不是逐个确认。
  • 按名称和参数缓存工具结果,让重复的文档查询不再重新爬取。
  • --model 固定一个模型,与 function_calling_default 对比工具选择的质量。
  • 把批准器换成一个策略函数:对特定参数下的特定 Actor 自动批准,其余一律询问。
想要一个不含 MCP 的更小起点,构建使用工具的智能体用三个本地 Python 函数讲了同一个循环。

收尾

感谢阅读!希望这篇文章帮你构建了一个用 Venice 思考、通过 Apify 行动的终端智能体。 值得带走的经验是:这些代码里与”智能”有关的部分少得惊人。模型返回工具调用,而你的代码决定哪些调用允许运行、结果如何回传、以及出错时会发生什么。一旦这些决策被写得明明白白,扩展能力大体上就只是把智能体指向更多工具的事了。