Upwork Scout 作者分享如何在 AI 匹配中控制成本的架构:用廉价过滤器先筛选,只在最后调用 LLM,将模型调用从「用户×作业」降低到「分类×周期」。
我在运营一个名为 Upwork Scout 的小型 SaaS。它全天候监控 Upwork,只把真正适合你的工作机会通过邮件发送给你。产品卖点一句话就能说清楚,工程实现却是一场与自家账单页面的漫长争论。
我之前写过这场争论中与抓取有关的部分。简单来说:最朴素的职位提醒工具会为每位用户单独抓取一次,因此成本会随着注册人数线性增长,最终让你因为自己的成功而破产。我把它反过来设计成:每个周期内,每个类别只进行一次共享抓取。现在,抓取成本取决于活跃类别的数量,而不是用户数量。
随后,我加入了用户真正愿意付费的部分——AI 匹配,并立刻制造出了第二张账单,而且它的扩展特性比第一张还要糟糕。抓取成本取决于类别数量,模型调用量则取决于职位数乘以用户数。正是这个乘法,让大多数“我们加入了 AI”的功能悄无声息地走向死亡。
下面是我最终采用的架构,以及帮助我走到这里的四条规则。
每位用户都有一组硬性筛选条件,大约二十项:工作类型、预算上下限、时薪范围、经验等级、项目周期、工作量、最大提案数、客户是否完成付款验证、客户最低历史支出、客户最低评分、雇佣次数、国家允许与拒绝列表,以及关键词包含与排除条件。
这些都不需要语言模型。它只是一个基于两个 struct 的纯函数,直接在内存中针对共享职位流运行,成本为零:
export function jobMatchesFilters(job: JobDoc, f: UserFilters): boolean {
const catHit = f.categories.some((c) => job.categories.includes(c));
// ... twenty more boring checks
return true;
}
AI 阶段只会处理已经通过这些筛选的职位。在一个正常周期里,这会在消耗任何一个 token 之前,就把候选集合缩小一个数量级。我写过最有效的 prompt 优化,其实就是一条 if 语句。
这个筛选器里有个细节,我花了一段时间才处理正确。Upwork 并不总会提供筛选条件所需的数据。有时客户的总支出缺失,有时没有评分,有时较早存储的职位发布于我后来新增某个字段之前。我的第一个版本会在数据缺失时直接拒绝,这意味着用户会悄无声息地停止收到提醒,而我却无法复现问题。现在,我把策略写在文件顶部,并始终如一地执行:只有存在明确证据时,筛选器才会拒绝。未知数据可以通过筛选,邮件和 dashboard 会标记这项未知信息,而不是隐藏该职位。抓取器无法判断的事情,就交给人来决定。
还有一个来自真实数据的小细节:Upwork 会在同一个字段中混用完整国家名称和 ISO3 代码。我曾在同一份数据里见过“United States”“USA”“CAN”“NLD”和“United Kingdom”。如果按字符串相等来筛选国家,那么你的 United States 筛选条件会漏掉所有标记为 USA 的职位,而你永远不会注意到,因为这种故障表现为一片沉默。因此,我维护了一张别名表。
Prompt caching 是个不错的优化,但在这里真正关键的并不是它。
扫描任务每十五分钟运行一次。如果不做缓存,同一位用户每天会针对同一个职位重复评分九十六次。由于职位和用户资料都没有变化,每次调用都会返回相同的答案。因此,判定结果才是缓存对象,而缓存键由这一对值组成:
matches/${uid}_${jobId} -> { score, reason, scoredAt }
同一职位永远不会针对同一用户评分两次,绝不例外。仅这一条规则,就让模型支出彻底与扫描频率脱钩。我可以改成每五分钟扫描一次,模型账单也不会增加,因为评分量的上限取决于新职位数乘以感兴趣的用户数,而不是 cron 的触发频率。
它还带来了一个我事先没有预料到的好处:永久记录每位用户为什么会看到每个职位。当有人发邮件问我,为什么他们会收到某条特定提醒时,我可以直接给出模型当时生成的那句确切理由,而不必靠猜测,也不用针对一个可能已经发生漂移的模型重新运行 prompt。
免费套餐用户每天可以获得 150 次 AI 评分。计数器保存在用户文档中,并附带日期,因此不需要清理任务也能自然重置:
let aiToday = u.aiScoresToday?.date === today ? u.aiScoresToday.count : 0;
真正重要的是用户达到上限之后会发生什么。最容易想到的做法,是当天剩余时间里直接跳过这位用户。但这是错误的选择,因为产品承诺的是职位提醒,而不是带评分的职位提醒。预算耗尽后,pipeline 会对剩余职位回退到仅使用筛选器的模式。用户只会在几个小时内收到精确度略低的提醒,而不是陷入沉默。
这是我在 AI 功能上不断重新学会的一条通用原则:模型应该是构建在一个原本就能正常运行的系统之上的增强层,而不是承重墙。如果你的功能没有为“模型没有回答”定义行为,那它就还没有开发完成。
评分函数只有一条不可违背的契约:无论是缺少 API key、网络错误、模型拒绝回答、JSON 格式错误,还是其他任何情况,它都只记录日志并返回 null。它所做的任何事情,都不能拖垮一个同时还在为其他所有人发送邮件的扫描周期。
消费端只有一行代码,而这也是我最谨慎对待的一行:
// Gate on threshold only when we actually have a verdict; AI failure => filters-only.
if (verdict && verdict.score < matching.threshold) continue;
如果缺少判定结果,职位就会通过。即使判断错误,发送一条不太匹配的职位,也好过因为判断错误而什么都不发送。因为用户收到一条质量一般的提醒,最多耸耸肩;用户完全收不到提醒,则会流失。
Prompt 本身很短,也毫不华丽。它只要求模型返回两项内容:一个 0 到 100 的整数,以及一句不超过十八个单词、点明决定性因素的理由。
真正发挥价值的是评分标准。如果没有明确的分数区间,评分就会集中在模糊的 70 到 85 分之间,阈值也会因此失去意义。所以我定义了不同区间,并要求模型果断判断:
90-100 perfect fit: core expertise AND exactly the kind of work they want
70-89 strong fit: clearly within their skills, only minor mismatches
50-69 decent fit: plausible, but notable skill gaps
30-49 weak fit: tangential; they could stretch to it but shouldn't
0-29 poor fit: wrong domain, or serious red flags
默认提醒阈值是 55,用户可以自行调整。
还有两件事,我会在任何类似项目中继续采用。
使用 structured outputs。响应受 JSON schema 约束,其中 score 是整数,reason 是字符串。我仍然会在输出时限制数值范围并进行取整,因为即使相信 schema,再做一次验证也几乎没有成本。但我已经不再编写正则表达式,从自然语言文本中挖取 JSON 了。
让解释成为产品的一部分。那句理由并不是 debug 输出。它会直接显示在提醒邮件的职位信息下方,而评分则用于对邮件中的职位排序,让最匹配的职位排在最前面。单独一个数字,是在要求用户信任你;一个数字再加上“与 Retell 和 n8n 高度重合,客户已验证,但预算低于你的 $50/hr 底线”,则能让用户在两秒内检查你的判断是否合理。与其花钱升级模型来换取准确率提升,这句话对用户留存的帮助反而更大。
说到模型:默认使用的是 Haiku 4.5,模型名称通过环境变量配置。根据我自己的估算,每次评分的成本大约是千分之一美元,这正是免费套餐能够存在的根本原因。如果有一天匹配质量取代成本,成为真正的限制因素,我只需修改一个环境变量,之后的所有评分就会变得更智能。到目前为止,我还没有这个必要。
困扰了我整整一周的问题,不是应该选择哪个模型,而是我能避免多少次调用。
先执行确定性筛选,因为它们免费,而且诚实。围绕任务的自然身份建立缓存,而不是围绕请求建立缓存。为每位用户设定一个硬性上限,并定义达到上限后系统如何继续运行。让模型失败时回退到非 AI 路径,而不是变成错误。
做到这些,模型才会成为它本来应该成为的东西:一条即使没有它也能正常运行的 pipeline 中,最后、最小、同时也最昂贵的那个步骤。
对于后续操作,你可以考虑屏蔽此人和/或举报滥用行为。