dev.to #ai短讯
设计能被 AI 智能体选中且负担得起的 Apify Actor
大多数 Apify Actor 面向人类用户,依赖 README 和截图交互。随着调用者变为 Claude、Cursor 等 AI 智能体,它们通过 MCP 服务器直接读取简短卡片和输入 Schema 执行任务,无法浏览图文详情或进行人工重试。本文指出需针对机器调用优化 Actor 的设计与定价策略。
大多数 Apify Actor 是为那些打开 Store 页面、阅读 README、填写表单并点击“开始”的人设计的。当出现问题时,这个人可以滚动页面、猜测原因并重试。
越来越多的调用者不再是人。Claude、Cursor 或自定义循环中的智能体连接到 Apify MCP 服务器,搜索 Store,阅读关于你的 Actor 的简短卡片,根据你的输入模式构建 JSON 输入,并在支出上限内运行它。它看不到你 README 中的截图。它无法询问“最大结果数”是什么意思。如果运行失败,它可能会简单地转向其他人的 Actor。
我是 Ryan Low。我在 Apify Store 上发布按事件付费的 Actor,包括 locaihost: SEC EDGAR 财务数据和内部交易、来自 Greenhouse/Lever/Ashby/Personio/Teamtailor 的招聘页面职位、英国/欧盟/加拿大政府招标,以及另外几个。这篇文章将介绍一次针对我的 SEC EDGAR Actor 的真实智能体调用,然后是使 Actor 对智能体可用(且负担得起)的设计决策。以下所有代码均来自实际发布的 Actor。我省略了任何我自己未测量的内容。
- 设置:一条命令
Apify 托管一个 MCP 服务器。在 Claude Code 中,你可以通过一条命令添加它:
claude mcp add apify https://mcp.apify.com/ -t http第一次使用时,会打开浏览器 OAuth 流程并要求你登录 Apify,因此无需粘贴令牌。之后,智能体拥有诸如 search-actors、fetch-actor-details、call-actor 和 get-dataset-items 等工具。
要将智能体固定到特定的 Actor,你可以在 URL 中传递它们(https://mcp.apify.com?tools=locaihost/sec-edgar,...)。每个列出的 Actor 都会作为其自己的工具出现。
- 智能体实际看到的内容
在为智能体编写任何内容之前,我阅读了 apify-mcp-server 的源代码。三个发现塑造了其他所有内容:
- search-actors 是 Store 搜索 API(GET /v2/store?search=...)的薄封装。MCP 服务器不会自行重新排序,因此智能体的排序就是 Store 的排序:相关性加上 Apify 用于 Store 的质量和流行度信号(可靠性、使用量、评论等)。
- 默认限制为 5。智能体被告知使用 1–3 个关键词进行搜索,如“SEC filings”或“insider trades”。只有五张卡片返回。
- 卡片由你的 Actor 的描述原文以及输入字段列表构建。你在 .actor/actor.json 的 description 中写的任何内容都是智能体阅读的推销语。
我需要诚实地说明前两点。我的 Actor 才几周大,几乎没有使用量。当我针对 Store API 运行智能体风格的查询时,它们都没有出现在我测试的任何查询的前 5 名中。一个新推出的 Actor 无论其模式多么清晰,都不会通过 search-actors 被选中。使用量建立排名,排名建立使用量,我没有找到捷径。
那么为什么还要费心呢?因为搜索只是其中一种方式。当以下情况发生时,智能体仍然可以访问新的 Actor:
- 用户命名它(“use locaihost/sec-edgar”),
- 它在 MCP URL 中使用 ?tools= 固定,
- 它出现在用户或智能体已经阅读过的 README、博客文章或已发布任务中。
在所有这三种情况下,智能体跳过搜索,直接进入 fetch-actor-details 和 call-actor。从那里,输入模式、故障模式和收费底线决定了调用是否成功。这部分你可以在第一天就控制。
- 一次真实的调用
以下是当我要求 NVIDIA 最近的内部交易时,Claude 通过 MCP call-actor 工具对 locaihost/sec-edgar 进行的调用:
{
"actor": "locaihost/sec-edgar",
"input": {
"companies": ["NVDA"],
"modes": ["insiderTrades"],
"maxResults": 5,
"onlyNew": false
},
"callOptions": {
"maxTotalChargeUsd": 0.1,
"memory": 1024
}
}智能体自主做出了两个很好的选择。它将 maxResults 设置为它实际需要的行数,并使用 maxTotalChargeUsd 将运行的总花费限制为一角钱。这两者只有在 Actor 尊重它们时才有效,我将在下面回到这一点。
结果,简化版:
状态:成功 运行时间:4.24 秒 计算单元:0.0012 项目数:5 字段(29):recordType, cik, ticker, companyName, form, filingDate, accessionNumber, insiderName, insiderCik, insiderType, insiderRole, isDirector, isOfficer, isTenPercentOwner, officerTitle, is10b51Plan, securityTitle, transactionDate, transactionCode, transactionLabel, acquiredDisposed, shares, pricePerShare, value, sharesOwnedAfter, directOrIndirect, ownershipNature, filingUrl, scrapedAt
五行数据,每一行都是一笔 Form 4 交易记录,耗时约四秒。字段名称本身已足以说明一切。一个旨在查明“谁卖出、卖了多少”的代理程序,会通过筛选 acquiredDisposed: "D" 且 transactionCode: "S" 的记录,并对 value 字段求和来实现这一目标。它不需要为此阅读任何 README 文档。
有一个字段值得仔细审视:对于自然人,insiderName 字段为 null。Form 4 申报文件会列出个人申报人,但该 Actor 刻意从不输出自然人的姓名。内部人士通过其角色进行标识(insiderRole: "Officer (CFO)", isDirector, isTenPercentOwner)。仅当涉及基金、有限责任公司和控股公司等组织时,才会输出名称:
// 法律实体标记。与这些标记都不匹配的名称被视为个人,且永远不会被输出。
export const isEntityName = (name: string): boolean =>
ENTITY.test(name.trim()) && !/\b(FAMILY|REVOCABLE|IRREVOCABLE|LIVING)\b/i.test(name);
// ...在 toTradeRecords() 内部:
const entities = owners.filter((o) => isEntityName(o.name));
insiderName: entities.length ? entities.map((o) => o.name).join('; ') : null,
insiderType: entities.length === owners.length && owners.length ? 'entity' : entities.length ? 'mixed' : 'individual',
insiderRole: owners.map(roleLabel).filter(Boolean).join('; '),家族信托和可撤销信托被视为个人。报告所有者的地址完全不会被读取。对于代理程序而言,这一点比对人类用户更为重要:代理程序会毫不犹豫地将你返回的任何内容复制到 CRM、电子邮件或报告中。如果个人数据从未出现在数据集中,它就永远不会从中泄露。我将“仅限业务数据”作为我所有 Actor 的硬性规定(我在新加坡,PDPA 是显而易见的顾虑)。对于内幕交易分析,角色和规模才是你真正需要的信息。
- 输入模式:一个明显的必填字段
输入模式成为代理程序的工具定义。我的模式包含十一个字段,但代理程序只需理解其中一个即可:
"companies": {
"title": "Companies",
"type": "array",
"description": "每行一家公司:美国股票代码(AAPL, BRK.B)、SEC CIK 编号(320193)或公司名称(Microsoft)。任何向 SEC 提交文件的上市公司。最多 500 家。",
"editor": "stringList",
"prefill": ["AAPL", "MSFT", "NVDA"]
}
// ...
"required": ["companies"]其他所有字段都有合理的默认值:三种模式、最近 90 天、8 个周期、最新申报优先。一个最小化的有效调用示例为 {"companies": ["NVDA"]}。
注意必填字段使用的是预填充(prefill),而非默认值(default)。这是最容易掉入的陷阱之一。如果一个必填字段同时拥有默认值,它在代理程序接收到的工具模式中就会静默地失去必填标志。对代理程序而言,该字段看起来是可选的。随后它会不带该字段调用 Actor,平台会填入你的默认值,导致代理程序在询问其他内容时却获得了 AAPL、MSFT 和 NVDA 的数据。prefill 会在控制台中为人类用户填写表单并仍显示示例值,但 required 属性得以保留。Apify 自身关于使 Actor 对代理程序可见的指南直言不讳地指出:带有默认值的必填字段始终是一个 bug。
我在每个模式中遵循的一些较小规则如下:
- 描述控制在 500 个字符以内。字段和 Actor 的描述最终会出现在工具定义和搜索卡片中。过长的描述会被截断或占用代理的上下文空间。将核心内容放在前面:需要输入什么、以何种格式,并附带一个示例。
- 接受模型可能发送的内容。companies 字段接受股票代码、CIK 编号或公司名称。sinceDate 字段接受“2026-07-01”、“90 days”或“6 months”等格式。模型通常会先尝试自然语言表述,因此应将其视为有效输入。
- 数组保持为数组。modes 是带有 enumTitles 的枚举数组,而不是需要代理猜测格式的逗号分隔字符串。
- 错误的输入不应导致运行失败
当代理发送 {"companies": ["NVIDIAA"]},或列表中每一项都是无效数据时,应该发生什么?
我最初的版本会调用 Actor.fail() 并附带清晰的错误信息。这看起来似乎很合理,直到我进行鲁棒性测试并发现了两个问题。失败的运行会计入 Actor 的成功率,进而影响质量评分,而质量评分又决定搜索排名(代理看到的也是同一排名)。此外,对代理而言,“FAILED”看起来像是“这个工具有故障”,而不是“你发送了错误的股票代码”。
因此,现在无效输入会导致 SUCCEEDED 状态结束,不产生任何费用,并返回一条说明如何修正的信息:
if (unresolved.length) log.warning(`Not found on EDGAR (use the ticker or CIK): ${unresolved.join(', ')}`);
if (!refs.length) {
// Unknown tickers are an input problem, not a crash: exit SUCCEEDED with nothing charged.
await Actor.setValue('SUMMARY', { since, modes: [...modes], pushed: 0, notFound: unresolved, companies: [] });
await Actor.exit(`No companies to look up${unresolved.length ? ` — not found on EDGAR: ${unresolved.slice(0, 20).join(', ')}` : ''}. Add tickers (AAPL), CIKs (320193) or company names (Microsoft).`);
}退出消息成为运行的状态消息,这是代理可以读取的运行详情的一部分。读到“在 EDGAR 上未找到:NVIDIAA。请添加股票代码 (AAPL)…”的代理会在下一次调用时自行纠正。Actor.fail() 仅保留用于真正的故障情况:如果所有公司查询都遇到上游错误,则运行失败,因为那时确实出现了问题。
careers-jobs Actor 更进一步,在获取任何数据之前拒绝无效输入(如句子、标记代码、300 字符的粘贴文本),因此 companies 数组中的提示注入垃圾永远不会转化为出站请求或产生费用。
- 你可信赖的按事件计费
我的所有 Actor 均采用按事件计费模式:极低的启动费加上每条记录一个事件的费用。对于 sec-edgar,基础层级每 1,000 条记录收费 2 美元,因此上述涉及五行的 NVIDIA 调用成本约为 1 美分。代理在这种模式下表现良好:它们只为实际使用的部分付费,且 maxTotalChargeUsd 为它们提供了硬性上限。
SDK 中存在一个陷阱。在按事件计费模式下,Actor.pushData(items, eventName) 返回一个 ChargeResult,告诉你有多少项被计费,从而被存储。当用户的计费限额在批次处理中途达到时,SDK 会丢弃剩余项。我的 Actor 采用批量推送(每次 API 调用 100 项),而对于批量操作,返回的 ChargeResult 可能会少计或多计。我不完全明白原因,但客户端在内部对请求进行分块可能是罪魁祸首。这一点很重要,因为我的“仅自上次运行以来新增”模式会记住哪些项已交付。如果信任错误的计数,用户要么为从未收到的项付费,要么再也看不到该项。
解决方案是停止信任返回值,而是在前后测量计费管理器的计数器。以下是 careers-jobs Actor 中实际的推送函数(sec-edgar 使用相同的函数,其中 EVENT = 'record'):
// 测量已计费事件计数器的增量;每次调用的 ChargeResult 对于批量处理来说并不可靠。 const charging = Actor.getChargingManager(); const isPpe = charging.getPricingInfo().isPayPerEvent; const emitter = new BatchEmitter<Job>( async (items) => { if (!isPpe) { await Actor.pushData(items); return undefined; } const before = charging.getChargedEventCount(EVENT); await Actor.pushData(items, EVENT); return { chargedCount: charging.getChargedEventCount(EVENT) - before, eventChargeLimitReached: charging.calculateMaxEventChargeCountWithinLimit(EVENT) <= 0, }; }, cap, (keys) => seenList.push(...keys), );
BatchEmitter 随后将 chargedCount 视为真实数据:仅统计该数量的项目为已发射,仅将这些项目的键加入“已见”列表,且一旦达到限制便干净利落地停止运行:
const res = (await this.push(batch.map((b) => b.item))) || {};
const stored = Math.min(res.chargedCount ?? batch.length, batch.length);
this.emitted += stored;
this.onStored(batch.slice(0, stored).map((b) => b.key));
if (res.eventChargeLimitReached || stored < batch.length) this.stopped = true;因此,被代理的十美分上限丢弃的项目不会被标记为已见,它会在下一次运行时重新出现。请注意,!isPpe 分支也很重要:在非按事件付费(例如本地运行)的情况下,尽管所有数据都已存储,ChargeResult 报告的已计费数量仍为零。
测试时还需知道一点:运行的 chargedEventCounts 和数据集 itemCount 在运行结束后会滞后几秒钟。在断定发现计费错误之前,请重新获取数据。
- 让小额预算进入
按事件付费的 Actors 有一个名为 minimalMaxTotalChargeUsd 的定价设置。这是调用者在启动运行时可以设置的最低 maxTotalChargeUsd。如果代理的预算低于你的底线,则运行不会启动。
代理通常会故意使用小预算。Claude 为五行查找选择了 0.10 美元,这对代理来说是一个合理的做法。如果底线设为 1 美元,就会阻止该调用。当我重新定价我的 Actors 时,我将所有 Actors 的底线降低到了 0.05 美元。
"minimalMaxTotalChargeUsd": 0.05两个相关的细节。首先,通过 API 更改按事件付费的定价时,你需要追加一个新的 pricingInfos 条目;替换现有条目会失败。其次,actor.json 中的默认运行内存设置为 1 GB,因为 Actor 启动事件是按内存的 GB 数计费的。代理很少覆盖内存设置,所以你选择的任何默认值就是他们支付的费用。
- 其他值得拥有的护栏
- 有文档记录的免费计划上限。在 Apify 的免费计划中,我的每个 Actor 每次运行最多返回 100 条记录,运行日志中也说明了这一点。看到 100 行数据和警告的代理可以向用户解释原因,而不是猜测。
- 尊重上游限制。sec-edgar 将所有请求通过一个全局速率限制器(每秒 8 次请求,符合 SEC 的公平访问限制),并声明了 User-Agent,当 SEC 进行节流时会退避。代理在循环中运行;你的 Actor 不应让循环击垮源端。
- 官方、公开的数据源。EDGAR 数据属于公共领域。这意味着代理(或其用户)在使用输出之前少了一桩需要担心的事。
- SUMMARY 记录。每次运行都会写入一个包含每家公司计数、备注和“未找到”列表的 SUMMARY 键值记录。获得零行的代理可以读取原因。
检查清单
如果你希望代理能够使用你的 Actor,并且负担得起:
- [ ] Actor 描述以动词开头,说明返回内容,且不超过 500 个字符。它是代理的工具卡片。
- [ ] 一个明显的必填字段,带有预填充值且无默认值。其余字段均为可选,并设置合理的默认值。
- [ ] 字段描述不超过 500 个字符,先列出核心要素,每个字段提供一个示例。
- [ ] 接受模型会尝试的自然格式(如股票代码和名称、“90 天”和 ISO 日期)。
- [ ] 无效输入应标记为 SUCCEEDED,不收费,并说明如何修正。Actor.fail() 仅用于真正的服务中断。
- [ ] 遵守 maxResults 和调用者的 maxTotalChargeUsd,在达到任一限制时优雅停止。
- [ ] 使用 getChargedEventCount() 的增量来计算费用,而不是批量的 pushData ChargeResult。
- [ ] minimalMaxTotalChargeUsd 设得足够低,以适应小型代理预算(我使用的是 0.05 美元)。
- [ ] 输出中不包含个人数据。通过角色而非姓名来识别人物。
- [ ] 预期搜索类 Actor 起初会忽略你。随着使用量的积累,通过名称、?tools= URL、README 和已发布任务让代理注意到你。
以上这些并不能让一个新的 Actor 出现在代理的前五名中。这需要使用量,而我目前的使用量还不多。但它的作用是确保当代理确实访问你的 Actor 时,第一次调用能够成功,费用符合预期,并为代理提供可用的结果。
关于我正在构建的内容的更多信息,请访问 locaihost.org。
译文已达到本站中文翻译的字数上限,剩余内容请查看原文。