你的 AI 智能体可以将多步工作流链接在一起,但除非你将其连接到数据库,否则它仍然无法回答“我上周的订单发生了什么?”这类问题。

我们在为 Laravel NovaSpark(我们面向客户的 SaaS 产品)构建支持智能体时遇到了这个瓶颈。智能体能够推理、在专家之间路由并进行对话。但当客户询问关于他们自身数据的问题时,它却一无所知。它无法拉取订单、检查订阅或查找帮助文章。所以我们将其连接了起来。

解决方案是使用 Laravel AI SDKTool,这是一个 PHP 类,告诉智能体它可以请求哪些数据以及如何获取。我们从单个工具开始,然后进一步推进到一个单一的查询构建器工具。以下是这两种方法。

如果你还没有设置 SDK,请先阅读介绍

构建 AI SDK 工具:一个工具,一个查询

每个 Laravel AI SDK 的 Tool 都定义了三个方法:何时使用它(description)、它接受什么输入(schema)以及如何运行查询(handle)。

使用 Artisan 命令生成:

php artisan make:tool SearchOrdersTool

这是一个按状态搜索用户订单的工具:

<?php

namespace App\Ai\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class SearchOrdersTool implements Tool
{
    public function __construct(private readonly int $userId) {}

    public function description(): Stringable|string
    {
        return '按状态搜索已验证用户的订单。返回订单 ID、总额和创建日期。';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'status' => $schema->string()
                ->enum(['pending', 'processing', 'shipped', 'delivered'])
                ->required(),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $orders = Order::query()
            ->where('user_id', $this->userId)
            ->where('status', $request['status'])
            ->select(['id', 'total', 'status', 'created_at'])
            ->limit(10)
            ->get();

        return $orders->toJson();
    }
}

注意 $userId 来自构造函数,而不是 handle() 内部的会话。没有它工具就无法工作,并且它由你的应用程序代码设置,而不是由智能体或用户的提示词设置。提示词注入无法更改返回哪个用户的数据。

select() 调用限制了智能体可以看到的列,limit(10) 保持了结果集足够小,不会占用智能体的上下文窗口。

向 Laravel AI 智能体注册数据库工具

通过实现 HasTools 将工具附加到智能体:

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\SearchOrdersTool;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;

class SupportAgent implements Agent, HasTools
{
    use Promptable;

    public function __construct(private readonly int $userId) {}

    public function instructions(): string
    {
        return '你是一名客户支持智能体。准确回答关于用户订单的问题。只分享你的工具返回的信息。';
    }

    public function tools(): iterable
    {
        return [
            new SearchOrdersTool($this->userId),
        ];
    }
}

然后调用它:

$response = (new SupportAgent(auth()->id()))
    ->prompt('我上周的订单发生了什么?');

智能体读取工具描述,选择合适的工具,并调用它。不需要路由逻辑。

对于覆盖多个领域的智能体,可以一起注册多个作用域限定的工具:

public function tools(): iterable
{
    return [
        new SearchOrdersTool($this->userId),
        new FetchInvoiceTool($this->userId),
        new CheckSubscriptionTool($this->userId),
    ];
}

每个工具只查询它需要的内容,只选择智能体应该看到的列,并限制结果大小。如果你的基础设施支持,可以更进一步使用只读数据库连接:

$orders = Order::on('readonly')
    ->where('user_id', $this->userId)
    ->where('status', $request['status'])
    ->select(['id', 'total', 'status', 'created_at'])
    ->limit(10)
    ->get();

即使某个巧妙的提示词欺骗智能体尝试写入,该连接在物理上也无法执行。

使用 Laravel SimilaritySearch 添加向量搜索

当客户第一次输入“我如何获得退款?”时,智能体直接卡住了。没有状态可以过滤,也没有订单 ID。它需要一种完全不同类型的搜索:语义搜索。

Laravel AI SDK 恰好为此内置了一个 SimilaritySearch 工具。向你的表添加一个嵌入列:

Schema::ensureVectorExtensionExists();

Schema::table('knowledge_articles', function (Blueprint $table) {
    $table->vector('embedding', dimensions: 1536)->nullable()->index();
});

然后注册它:

use App\Models\KnowledgeArticle;
use Laravel\Ai\Tools\SimilaritySearch;

public function tools(): iterable
{
    return [
        SimilaritySearch::usingModel(KnowledgeArticle::class, 'embedding')
            ->withDescription('搜索知识库中与用户问题相关的文章。'),
    ];
}

要将结果限定给当前用户,使用闭包形式:

public function tools(): iterable
{
    return [
        new SimilaritySearch(using: function (string $query) {
            return KnowledgeArticle::query()
                ->where('user_id', $this->userId)
                ->whereVectorSimilarTo('embedding', $query)
                ->limit(5)
                ->get();
        }),
    ];
}

你也可以设置最低相似度阈值:

SimilaritySearch::usingModel(
    model: KnowledgeArticle::class,
    column: 'embedding',
    minSimilarity: 0.7,
    limit: 5,
)

minSimilarity 为 0.7 意味着智能体只能获得与问题语义相似度至少为 70% 的结果。噪音更少,答案更好。

单个工具加上 SimilaritySearch 已经带我们走了很远。每个工具都很专注、可测试且天生安全。然后范围扩大了。

当单个工具停止扩展时

随着我们不断构建 Nova 和 Spark 的支持智能体,工具数量持续攀升。每个新的客户问题都意味着另一个 PHP 类。像“我使用的是 Pro 套餐,但我看不到分析仪表板”这样的提问需要一个工具来检查功能权限。“我上个月被扣了两次费”需要一个工具来拉取发票历史记录。“我的订阅显示已过期,但我昨天刚续费”又需要另一个工具来交叉核对支付记录与订阅状态。

我们达到了十几个工具,两个问题开始累积:

  • 首先,每个工具的描述和模式都会作为提示词的一部分发送给模型。在客户提出问题之前,十几个工具就意味着一大块上下文窗口被工具定义占用了。
  • 其次,智能体可供选择的工具越多,它选错工具的频率就越高。我们曾看到它在应该调用 CheckSubscriptionTool 时调用了 FetchInvoiceTool,或者幻想了模式中不存在的参数。工具越多,模型就越容易混淆。

除此之外,差距也不断出现。智能体会在一个完全合理的问题上失败,不是因为数据不存在,而是因为还没有人编写那个特定的工具。

所以我们问了一个不同的问题:如果我们不再试图预测智能体需要的每一个查询,而是只给它只读访问权限呢?

中间方案:为 Laravel AI 智能体构建一个数据库查询工具

我们没有用十几个单独的工具,而是构建了一个由 Laravel 的查询构建器支持的单一工具。智能体发送结构化的参数(表、列、过滤器),工具使用 DB::table() 构建一个安全的查询。

使用 Artisan 生成工具:

php artisan make:tool DatabaseQueryTool

为你的 AI 智能体定义数据库模式

description() 是智能体的数据库地图。它告诉模型哪些表和列存在以及它们如何关联。智能体不会自己发现你的模式。

public function description(): Stringable|string
{
    return <<<'DESC'
    查询数据库以获取用户、订单、工单、订阅和发票数据。

    模式参考(表:关键列):
    - users: id, name, email, plan, last_login_at, created_at
    - orders: id, user_id, total, status, created_at
    - tickets: id, user_id, subject, priority, status, assigned_to, created_at, resolved_at
    - subscriptions: id, user_id, plan, status, started_at, expires_at, created_at
    - invoices: id, order_id, user_id, amount, status, due_at, paid_at, created_at

    关联关系:
    - orders.user_id -> users.id
    - tickets.user_id -> users.id
    - subscriptions.user_id -> users.id
    - invoices.order_id -> orders.id, invoices.user_id -> users.id
    DESC;
}

定义输入

schema() 告诉模型它可以发送哪些参数。table 上的 enum() 直接在模式层面将智能体限制在你批准的表。where 参数使用一个类型化的对象数组,这样模型发送的是结构化的过滤器,而不是自由格式的文本。

public function schema(JsonSchema $schema): array
{
    return [
        'table' => $schema->string()
            ->enum(['users', 'orders', 'tickets', 'subscriptions', 'invoices'])
            ->required(),

        'columns' => $schema->array()
            ->items($schema->string())
            ->description('要选择的列名。')
            ->required(),

        'where' => $schema->array()
            ->items($schema->object([
                'column' => $schema->string()->required(),
                'operator' => $schema->string()->default('='),
                'value' => $schema->string()->required(),
            ]))
            ->description('过滤条件。'),

        'limit' => $schema->integer()
            ->description('要返回的最大行数。')
            ->default(10),
    ];
}

当智能体收到“我的订阅状态如何?”时,它会发送类似这样的内容:

{
  "table": "subscriptions",
  "columns": ["plan", "status", "expires_at"],
  "where": [{ "column": "user_id", "operator": "=", "value": "1" }]
}

安全地构建查询

handle() 方法是查询构建器发挥作用的地方。PHP 数据对象(PDO)会自动绑定智能体传递的每个值,因此通过过滤器值进行的 SQL 注入已为你处理。但 PDO 无法绑定列名或操作符。这些会原样插值。所以我们在它们接触查询之前,根据允许列表对它们进行验证。

private const array ALLOWED_COLUMNS = [
    'users' => ['id', 'name', 'email', 'plan', 'last_login_at', 'created_at'],
    'orders' => ['id', 'user_id', 'total', 'status', 'created_at'],
    // ... 同样适用于 tickets, subscriptions, invoices
];

private const array ALLOWED_OPERATORS = [
    '=', '!=', '<', '>', '<=', '>=', 'like',
];

handle() 的核心功能验证列、构建查询、应用过滤器并返回结果:

public function handle(Request $request): Stringable|string
{
    $table = (string) $request->string('table');
    $columns = $request->array('columns');
    $allowed = Arr::get(self::ALLOWED_COLUMNS, $table, []);

    // 拒绝任何不在允许列表中的列
    if ($error = $this->validateColumns($columns, $allowed, $table)) {
        return $error;
    }

    // 使用查询构建器构建查询
    $query = DB::table($table)
        ->select($columns)
        ->limit(min($request->integer('limit', 10), self::MAX_ROWS));

    // 验证并应用每个过滤条件
    if ($error = $this->applyFilters($query, collect($request['where'] ?? []), $allowed, $table)) {
        return $error;
    }

    // 运行查询、编辑敏感列、限制输出大小
    $rows = $query->get()
        ->map(fn (object $row): array => $this->redact((array) $row))
        ->all();

    return $this->formatOutput($rows);
}

Laravel AI SDK 的 Request 类通过 Laravel 的 InteractsWithData trait 为你提供了类型化的访问器,如 $request->string()$request->array()$request->integer()

编辑敏感数据

即使有了列允许列表,随着你的模式增长,你可能仍会意外暴露一个敏感列。redact() 方法会捕获以 _token_secret_password_key 结尾的列,并在它们到达智能体之前替换其值:

private const array REDACTED_SUFFIXES = [
    '_token', '_secret', '_password', '_key',
];

protected function redact(array $row): array
{
    return collect($row)
        ->map(fn (mixed $value, string $column): mixed => Str::endsWith(
            Str::lower($column), self::REDACTED_SUFFIXES
        ) ? '[REDACTED]' : $value)
        ->all();
}

限制输出大小

一个返回 10,000 行的查询会耗尽智能体的整个上下文窗口。formatOutput() 方法将响应限制在 MAX_OUTPUT_LENGTH 字符以内。

技巧在于避免暴力循环。我们不是一次又一次地对数组进行二分(每次都重新编码数千行),而是编码一次,估算实际能容纳多少行,然后直接切片:

protected function formatOutput(array $rows): string
  {
      $result = ['rows' => $rows, 'count' => count($rows), 'truncated' => false];
      $json = json_encode($result);

      if (Str::length($json) <= self::MAX_OUTPUT_LENGTH) {
          return $json;
      }

      // 我们知道总大小和行数,因此可以估算出在我们的预算内容纳多少行
      $averageRowSize = Str::length($json) / count($rows);
      $rowsThatFit = (int) floor(self::MAX_OUTPUT_LENGTH / $averageRowSize);

      // 切片到估算值的 85% —— 行大小各异,因此我们留出余量以避免超出
      $safeLimit = max(1, (int) ($rowsThatFit * 0.85));
      $rows = array_slice($rows, 0, $safeLimit);

      $result = ['rows' => $rows, 'count' => count($rows), 'truncated' => true];
      $json = json_encode($result);

      // 如果估算仍然过于宽松(例如,某行有一个巨大的文本列),则进行二分直到合适
      while (Str::length($json) > self::MAX_OUTPUT_LENGTH && count($rows) > 1) {
          $rows = array_slice($rows, 0, intdiv(count($rows), 2));
          $result = ['rows' => $rows, 'count' => count($rows), 'truncated' => true];
          $json = json_encode($result);
      }

      return $json;
  }

这通常在一次传递中就完成了。二分回退是一个安全网,很少触发,但保证了输出总是合适的。

为什么查询构建器让这变得更简单

你不需要关键字黑名单、注释剥离或多语句预防。查询构建器在物理上无法产生 DELETEDROP 或任何写操作。安全性来自 API 的结构,而不是来自解析 SQL 字符串。

保护你的 Laravel AI SDK 数据库工具

代码级别的允许列表只是一半。另一半是你在工具周围设置的东西。

使用只读数据库用户: 创建一个专用的 MySQL 或 Postgres 用户,在你暴露的表上只授予 SELECT 权限。使用 DB::connection('readonly') 将工具指向该连接。即使每个应用级别的检查都以某种方式失败了,数据库本身也会拒绝写入。

记录工具调用: Laravel AI SDK 会在每次工具调用时分发事件。记录表名、请求的列和过滤条件,以便你审计智能体实际查询的内容。如果相同的模式不断出现,这是一个信号,表明你可能需要为那个特定查询提供一个专用的 Eloquent 工具。

测试对抗性提示词: 编写试图诱骗智能体查询允许列表之外的表、选择你未批准的列或使用你已阻止的操作符的提示词。将这些作为测试套件的一部分运行。允许列表应该能捕捉所有情况,但在发布之前验证它可以建立信心。

它很好地处理了我们的大部分支持问题。“我的订阅状态如何?”“显示我最近的发票。”简单的过滤器和查找效果很好。

智能体可以做什么

无需为每个问题编写单独的工具类,智能体开始自行处理用户查询:

  • “我上周升级到了 Pro,但我仍然看到免费套餐的限制。”智能体查询了按用户和状态过滤的 subscriptions 表,找到了活跃记录,并确认 plan 字段与用户期望的一致。
  • “你能告诉我过去三个月我花了多少钱吗?”它查询了按用户和日期范围过滤的 invoices 表,然后在响应中合计了金额。
  • “我两天前提交了一个关于此事的支持工单,但还没有收到回复。”智能体查询了按用户和日期过滤的 tickets 表,找到了工单,并提取了其当前状态和分配情况。

在 Eloquent 工具和查询构建器工具之间选择

我们尝试了两种方法,它们解决不同的问题。

单独的 Eloquent 工具是最安全、最可预测的。每个工具做一件事,模式严格约束输入,并且你可以精确控制查询的构建方式。更易于测试,更易于推理。如果你预先知道你的查询且领域较窄,从这里开始。

查询构建器工具给你一个工具而不是十几个。PDO 处理值绑定,列和表允许列表防止智能体接触任何你未批准的内容,操作符允许列表保持比较安全。你不需要关键字黑名单或注释剥离,因为无论智能体发送什么,查询构建器都无法产生 DELETEDROP。其代价是复杂的连接和聚合更难表达为结构化参数,但对于大多数支持和查找用例来说,它很好地覆盖了范围。

单独的 Eloquent 工具 SimilaritySearch 查询构建器工具
最佳适用场景 已知、可预测的查询 模糊/语义问题 不可预测的查询模式
设置工作量 每个查询一个类 一个工具 + 向量列 一个工具 + 允许列表
上下文窗口成本 随工具数量增长 单一工具 单一工具
安全模型 构造函数作用域的用户 ID 闭包作用域或模型作用域 允许列表 + 只读连接
连接/聚合 完整的 Eloquent 能力 不适用 限于简单查询

对于大多数应用,从单独的 Eloquent 工具开始,并为模糊问题添加 SimilaritySearch。如果你发现自己每周都在编写一个新工具,那就转向查询构建器方法。

查询构建器路径对 Nova 和 Spark 有效,因为支持领域跨越五个表,而且用户提出的问题不可预测。如果我们逐个编写工具,是无法达到相同的覆盖范围的。

下一步

我们正在探索如何将此模式引入 Laravel AI SDK 本身,提供内置的安全护栏,这样你就不必自己连接它们了。

与此同时,单独工具模式和 SimilaritySearch 如今都已为生产环境做好准备。从一个作用于已验证用户的单一 Eloquent 工具开始,返回一个小型的 JSON 负载,并将你选择的列限制在智能体实际需要的范围内。关于多智能体工作流模式的文章展示了这些工具如何组合成更大的流水线。

如果你想尝试查询构建器方法,本文中的所有内容都会指引你。阅读 Laravel AI SDK 工具文档以获取完整的 Tool 接口参考。

常见问题解答

如何将 Laravel AI SDK 连接到我的数据库?

实现 Tool 接口,并提供一个运行 Eloquent 或查询构建器查询的 handle() 方法。通过构造函数传递已验证的用户 ID,而不是会话。

如何向 Laravel AI 智能体添加向量搜索?

使用内置的 SimilaritySearch 工具。使用 $table->vector('embedding', dimensions: 1536) 向你的表添加一个向量嵌入列,然后使用 SimilaritySearch::usingModel() 注册该工具。需要 支持 pgvector 的 Postgres

如何防止 Laravel AI SDK 工具中的提示词注入?

所有查询都通过构造函数传递的用户 ID(而不是来自提示词)来限定作用域。使用列允许列表、操作符允许列表和只读数据库连接。查询构建器在物理上无法产生 DELETEDROP 语句。

我应该使用单独的 Eloquent 工具还是单一的查询构建器工具?

当你预先知道你的查询时,从单独的 Eloquent 工具开始。当达到十几个工具且上下文窗口开销成为问题时,再转向查询构建器方法。