我最初开发我们的人工智能助手时,它只有五个工具:查询订单、处理退款、查看票务情况。都是些简单的功能。六个月后,我们的工具数量已经接近 40 个,涵盖订单、活动、营销活动、竞赛和客户管理等领域。
在一次例行成本审查中,问题变得显而易见:我们每次请求都浪费数千个代币,而这些请求仅仅是为了描述模型永远不会用到的工具。比如有人问“我的节目几点开始?”,我们却发送了process_refund 、 create_email_campaign和manage_contest_prizes的完整规范。这太浪费了。
工具爆炸问题
每个工具定义都不简单。你需要一个名称,一段足够详细的描述(以便LLM能够理解何时使用它),以及包含类型和约束的参数规范。以下是我们代码库中的一个示例:
% ToolDefinition { 姓名: "处理退款" , 描述: """ 处理特定订单的退款。验证退款金额 与原订单总额和可用余额进行比较。需要 从 get_order_details 获取 order_id。返回包含退款 ID 的确认信息。 """ , 参数: [ %{姓名: "order_id" , 类型: :细绳, 必需的: 真的}, %{姓名: “数量” , 类型: :数字, 必需的: 真的}, %{姓名: “原因” , 类型: :细绳, 必需的: 错误的} ], 处理程序: {退款登记处, :handle_process_refund }, 类别: 退款 }
乘以 40,用户还没开口说话,就已经产生了 3000 多个令牌。成本不断累积,延迟增加,更糟糕的是:工具太多反而会降低模型选择正确工具的能力。噪音越多,混乱越多。
基于嵌入的语义选择
解决方案的概念很简单。我们不再每次请求都发送所有工具,而是将所有工具描述嵌入到向量中,并使用 pgvector 将它们存储在 Postgres 数据库中。当收到查询时,我们也将其嵌入到向量中,然后使用余弦距离找到语义最相似的 5-10 个工具。
查询“退款订单 #12345”被嵌入,与所有工具嵌入进行比较,并返回process_refund 、 calculate_refund_amount和get_order_details 。我们只将这些发送给 LLM。
这可以将我们工具在大多数请求中的负载减少 75% 到 90%。模型看到的选项更少,但更相关,因此能够做出更好的选择。
选择嵌入提供商
我们讨论了两种主要方法:调用 OpenAI 的嵌入 API 或运行我们自己的模型。
OpenAI 的text-embedding-3-small是阻力最小的方案。它通过 REST 调用返回 1536 维向量,每个嵌入的成本仅为百分之一美分左右,而且开箱即用。语义理解能力非常出色。缺点在于它对外部资源的依赖。每次查询都需要一次网络往返,你的数据会经过他们的服务器,并且你会受到他们的速率限制和服务器故障的影响。
在本地运行 ModernBERT 之类的模型有很多吸引人的地方。它几乎没有边际成本,延迟低于毫秒级(因为无需网络跳转),并且完全保障数据隐私。但与此同时,你也需要管理基础设施。你需要一台运行模型的服务器,还要考虑监控、扩展性等问题,并且模型的选择和更新也由你负责。对于小型团队来说,这种运维负担是实实在在的。
还有一种混合方法:在生产环境中使用 OpenAI 以确保可靠性,在开发和测试阶段运行本地模型以避免 API 成本和不稳定性。我们通过构建提供程序抽象层来实现这一点:
defmodule Amplify.EmbeddingProvider 做 @打回来 generate_embedding ( String.t ( ) ) :: { :好的, list ( float ())} | { :错误, 任何()} @打回来 方面() :: pos_integer () @打回来 model_id () :: 字符串.t ( ) 结尾
切换服务提供商需要更改配置。虽然前期需要额外花费一个小时,但可以带来后续的灵活性。
我们为什么选择 OpenAI
就我们的数据量而言,OpenAI 显然是最佳选择。我们每天处理数百个查询,而不是数百万个。每个嵌入仅需 0.00001 美元,每月成本几乎可以忽略不计。它的可靠性极佳,语义质量也完全满足我们的电商领域需求,而且无需任何基础设施管理。
如果我们要处理数百万条查询,或者有严格的数据驻留要求,那么情况就不同了。但对于一个运营工单平台的小团队来说,花几分钱就能避免运行其他服务,这笔交易很划算。
在开发过程中生成嵌入
添加新工具或更新现有工具意味着需要重新生成嵌入。在开发过程中,这是一项混合任务:
混合 生成工具嵌入
此操作会遍历所有工具定义,对每个工具调用 OpenAI,并将结果更新插入到tool_embeddings表中。处理 40 个工具大约需要 10 秒。该任务是幂等的,因此您可以随时运行它。
实现方法很简单。我们将每个ToolDefinition转换为嵌入文本,其中包含名称、描述和参数信息,然后将该向量与工具名称和模型 ID 一起存储。
生产环境中的嵌入生成
为了方便生产环境使用,我们搭建了一个简单的管理页面。用户只需导航至 AI 操作界面,即可查看当前的嵌入次数,然后点击按钮重新生成。非技术团队成员无需操作控制台,即可在工具更新后触发此功能。
另一种方法是直接入侵生产控制台:
Amplify.Services.ToolSelector.regenerate_embeddings ( )
无论哪种方式,随时都可以安全地运行重新生成功能。它会删除现有的嵌入并创建新的嵌入。整个过程只需几秒钟。
需要注意的是:如果您更换了嵌入提供商,则必须重新生成所有内容。OpenAI 的 1536 维向量与本地模型的 768 维向量不兼容。我们在每个嵌入中存储了model_id ,以便捕获不匹配的情况并简化调试过程。
处理多步骤操作
纯粹的相似性搜索存在缺陷。如果有人说“退款订单 #12345”,我们会找到process_refund 。但 LLM 还需要get_order_details来查找订单详情,才能进行退款。这两个函数的语义相似度不够高,无法同时出现在搜索结果的前列。
我们通过扩展类别解决了这个问题。每个工具都有一个类别,例如:orders :refunds或:events 。当我们通过相似性选择工具时,我们会扩展类别以包含相关类别:
@category_expansions %{ 订单: [ :orders , 退款, :顾客], 退款: [ :orders , 退款, :付款], 事件: [ :events , :tickets ] }
因此,查找process_refund (类别:refunds )会自动调用订单查询工具。LLM 即可获得多步骤工作流所需的一切信息。
pgvector 查询
对于那些对数据库方面感兴趣的人,以下是我们实际运行的查询:
选择 姓名, 1 - (嵌入) <=> 1美元) 作为 相似 从 工具嵌入 在哪里 (嵌入) <=> 1美元) <= 3美元 命令 经过 嵌入 <=> 1美元 限制 2美元
<=>运算符是 pgvector 的余弦距离。我们使用相似度阈值(默认为 0.4)进行过滤,以避免返回完全不相关的工具,然后取前 K 个结果。整个过程运行时间不到 10 毫秒。
测试而不影响 OpenAI
我们在测试中使用 Mimic 进行模拟。每个涉及工具选择的测试都会对嵌入提供程序进行桩化,使其返回一致的向量:
模仿.存根(嵌入提供程序, :产生, 函数 _文本 -> { :好的, 列表.重复( 0.1 , 1536 )} 结尾)
这样可以保证测试快速、确定性强,并且不依赖于 API。我们还可以模拟故障,测试系统在嵌入生成失败时能否优雅地回退到使用所有工具。
我们学到了什么
一路上发生了一些让我们感到意外的事情。
相似度阈值比我们预想的更重要。阈值太高会过滤掉有用的工具,阈值太低则只会得到噪声。经过一些实验,我们最终将阈值设为 0.4,但建议根据你的领域进行调整。
类别扩展最初是事后考虑的,但后来却变得至关重要。纯粹的语义相似性忽略了工具之间的依赖关系。如果你的助手需要执行多步骤操作,你就需要类似这样的功能。
即使我们没有更换提供商,提供商抽象也物有所值。它迫使我们以清晰的思路思考接口,并大大简化了测试。Mimic 桩之所以有效,是因为模拟的边界很明确。
冷启动确实是个问题。如果你的嵌入表为空,就需要备用方案。我们会记录警告并使用所有工具,虽然这并非理想方案,但可以避免彻底失败。
结果
部署此方案后,我们用于工具定义的单次请求令牌使用量下降了 60-80%。由于模型处理的令牌数量减少,延迟降低了约 200 毫秒。工具选择的准确率实际上略有提高,因为干扰模型的噪声减少了。
嵌入成本可以忽略不计。以我们的数据量来看,每天的成本可能只有 0.01 美元。整个系统每次请求会增加一次 10 毫秒的数据库查询,但这在 LLM 调用产生的噪声中几乎可以忽略不计。
对于任何需要处理人工智能代理工具爆炸问题的人来说,这种方法都值得考虑。它的实现并不复杂,成本极低,而且随着工具数量的增加,收益也会成倍增长。
原文: https://zarar.dev/embedding-based-tool-selection-for-ai-agents/