我们要构建什么
参考实现是一个小型 Python 包,每个模块只负责一件事:
一个问题在其中的流转过程是这样的:
- 除非你固定了模型,否则向 Venice 询问当前的函数调用模型。
- 连接 Apify MCP 服务器并列出它的工具。
- 把这些 MCP 工具改写成兼容 OpenAI 的函数定义。
- 把问题连同工具列表一起发送。
- 如果模型返回
tool_calls,就在 Apify 上执行它们,并把结果作为tool消息追加进去。 - 重复,直到模型给出文本回答而不是工具调用。
这个智能体可能会消耗你账户里的 Apify 算力。如果你只需要搜索和文档工具,就先不要设置
APIFY_TOKEN;在你真的打算运行 Actor 之前,也不要加 --yes。搭建项目
参考项目使用 Python 3.12+ 和 uv。 创建一个新项目:httpx2 是 httpx 的 2.x 系列,openai 和 mcp 都已经依赖它。直接安装它可以避免同一个环境里出现两个 HTTP 客户端。
然后创建一个 .env 文件:
VENICE_API_KEY 来自 Venice API 设置。APIFY_TOKEN 来自 Apify Console,而且是可选的——稍后我们会讲不设置它时你能得到什么。
加载配置
配置放在最前面,因为其他每个模块都会把它作为参数接收。我们用pydantic-settings,让环境变量、.env 和 CLI 参数都汇入同一个经过校验的对象。
在 src/venice_terminal_agent/config.py 中,一个 Settings(BaseSettings) 类承载关键字段:
venice_model 是 None 而不是某个模型 ID,下一节会回到这一点。而 max_rounds 和 max_tool_result_chars 是防止智能体失控的两道限制:前者限制一个问题最多能进行多少轮工具调用,后者限制抓取到的页面有多少内容会被送回上下文。
这个模块里有意思的函数是 URL 构建器:
tools 查询参数,决定它对外公布哪些工具。没有 APIFY_TOKEN 时,我们请求那四个无需认证即可使用的匿名工具——Actor 搜索、Actor 详情、文档搜索和文档获取。这意味着任何人克隆这个项目、只添加一个 Venice key,也能得到一个可以调研 Apify Actor 的可用智能体。只是无法运行 Actor。
与 Venice 通信
Venice 兼容 OpenAI,所以 chat completions 可以用 OpenAI SDK,模型发现调用则用普通的httpx。
创建 src/venice_terminal_agent/venice.py:
AsyncOpenAI 免费提供流式辅助方法和带类型的 tool_calls。原生 httpx 客户端则用于 OpenAI SDK 不认识的 Venice endpoint,在这个项目里就是 /models/traits。
聊天超时远长于发现调用的超时是有意为之。一个会触发网页爬取的问题,合理地花上几分钟并不奇怪。
在运行时发现模型
Venice 的模型 ID 会轮换,硬编码某一个是让智能体在一个月内坏掉的最快方式。GET /models/traits 把稳定的 trait 名称映射到当前担任该角色的模型上,所以我们请求 function_calling_default,而不是点名某个模型:
--model 参数最优先,其次是环境变量里的 VENICE_MODEL,最后才是 trait 查询。因此默认路径完全不需要配置,而当你想对比两个模型的行为时,仍然可以固定一个。
流式补全
现在加入补全调用:stream() 上下文管理器两头都管:content.delta 事件驱动终端输出,get_final_completion() 则交回一条 tool_calls 已经拼接完毕的完整消息。
请求本身由一个独立函数构建,这样便于测试:
extra_body 是 OpenAI SDK 透传它未建模字段的方式,venice_parameters 就放在这里。把 include_venice_system_prompt 设为 false 可以让 Venice 的默认助手 prompt 不进入对话,这样我们自己的系统 prompt 就是模型收到的唯一指令。对一个有严格工具规则的智能体来说,这正是你想要的。
只有在至少有一个工具时才附上 tools 和 tool_choice。发送一个空的 tools 数组,只会平白让模型困惑。
这个模块还有一个 format_http_error() 辅助函数,把 APIStatusError 或 httpx2.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 的块。它以最要紧的两行收尾:
{"error": "..."} 而不是抛出。一次失败的工具调用是模型可以据此行动的信息——它可以换一个 Actor 或修正参数——而前提是失败以普通工具结果的形式到达它那里。
标记会花钱的工具
Apify 工具可以干净地分成两组:读取元数据和文档的,以及启动算力的。我们希望第二组需要确认,所以把第一组放进允许列表:通过 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。
两种传输方式如下:
APIFY_TOKEN,而不是你的整个 shell 环境——包括你的 Venice key 在内。
执行工具调用
这个模块的最后一块是execute_venice_tool_call(),它把一次 Venice 工具调用变成字符串结果。它把两类失败——无法解析的参数和失败的 Apify 调用——都包装成 {"error": "..."} 而不是抛出:
{"error": "invalid arguments: ..."} 交给模型,下一轮就能得到修正过的调用;而抛出异常会杀死会话、丢掉对话。
运行工具循环
现在轮到智能体本身了,位于src/venice_terminal_agent/agent.py。先从系统 prompt 开始:
search-actors 和 fetch-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),而它会破坏工具调用。工具调用回合的 content 是 null,丢掉这个键会改变你回传消息的形状。exclude_unset=True 才是你要的版本:它保留模型确实设置了的 null 值,并省略它从未发送过的字段。
它还保留了 OpenAI schema 不认识的字段。推理模型会返回 reasoning_content 和 reasoning_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 客户端都需要收尾:
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_TOKEN 没有加载成功——现在就发现,可比困惑十分钟为什么智能体拒绝运行 Actor 之后才发现好得多。
当你清楚自己需要什么时,收窄工具目录:
--tools 是收窄选择范围最省事的方式。
在本地运行 MCP 服务器,而不是使用托管版:
npx 启动 @apify/actors-mcp-server,而且需要 APIFY_TOKEN——本地服务器没有匿名模式。
而当你确实想要无人值守地运行 Actor 时:
测试各个部件
这里所有有意思的逻辑都不需要网络。一个从预设回复列表里弹出消息的FakeVenice,加上一个用 SimpleNamespace 工具构建真实 ToolCatalog 的 FakeApify,就足以驱动一整轮工具调用:
agent.messages 做断言。其一,失败的运行会把历史回滚到只剩 ["system"],无论失败原因是 Venice 报错还是耗尽了 max_rounds。其二,即便批准器返回 False,只读工具仍然会运行。其三,被拒绝的付费工具会留下一条包含 declined 的 tool 消息,而 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 自动批准,其余一律询问。