Hotdry.

Article

数据库并非为代理式 AI 工作负载而生:防御性设计模式解析

解析关系型数据库在代理式 AI 工作负载下的架构困境,提供软删除、连接池隔离、幂等键等防御性工程化参数与实战代码。

2026-04-26systems

在传统数据库架构的深处,隐含着一份从未被书面化的契约。这份契约大致如此:调用方是人类编写的应用程序,运行确定性代码,发布可预测的查询,部署前经过开发者审核。写入是有意为之的,连接是短暂的,出了问题会有人注意到。数据库可以愚笨而快速,因为应用层是智能而审慎的。

过去四十年,这份契约一直有效。它塑造了我们设计 Schema、配置连接池、授予权限以及思考故障模式的方式。然而,代理式 AI 系统同时在每一层打破了这个假设。本文将深入分析哪些具体假设正在失效、为何重要,以及可落地的工程化应对方案。

核心矛盾:被打破的隐含契约

传统数据库架构围绕五个核心假设构建,而这些假设在代理式 AI 场景下逐一失效。

第一个假设是确定性调用方。在传统应用中,投递到数据库的查询由人类编写:开发者编写 SQL、进行代码审查、测试后部署。这种假设根深蒂固,以至于工具链都自动反映了它。PostgreSQL 查询规划器围绕观察到的查询模式构建统计信息,缓存层针对重复查询预热,连接池围绕已知复杂度的并发查询数量进行调优。但代理式 AI 工作方式不同:它们通过推理生成查询,不同的推理路径会产生针对同一表的不同查询。一个处理客户分析任务的代理可能发起一个从未有人执行过的五表连接,持有连接而暂停思考,然后发布一个完全不同的后续查询。传统索引覆盖的是快乐路径,连接池按观察到的峰值配置,这些在代理可以任意构建查询的情况下都不再成立。

第二个假设是写入是有意的。数据库架构中最危险的假设是每次写入在发生前都经过人类审核。这在你整个职业生涯中基本上是真实的,但现在不再成立。代理自主写入,基于当前对任务的理解(可能错误),在工具返回意外结果时循环写入,在瞬态网络错误导致代理 “认为” 第一次尝试失败时重试写入。代理可能在短短几秒钟内写入数千行,而你收到 Slack 通知时已经太晚。

第三个假设是连接是短暂的。传统连接池大小的调整遵循一个简单的心理模型:应用处理 N 个并发请求,每个请求需要在一个短暂的时期内使用一个数据库连接。你将池大小调整到略高于预期的并发峰值,稍作缓冲,就完成了。但代理在三个方面打破了这个模型。首先,代理持有连接的时间更长。一个多步推理任务可能发布一个查询,暂停以使用 LLM 处理结果,再发布另一个查询,再次暂停。每次暂停都保持连接打开。任务每连接时间不再是 “查询执行时间”,而是 “查询执行时间加 LLM 推理时间乘以推理步数”。其次,代理会扇出。一个单一的高级代理任务通常会生成并行工作的子代理。一个任务变成五个同时的数据库会话。当并发代理工作流持有打开的 db.session 时,这可能会耗尽连接,直到 Postgres 耗尽连接槽。第三,代理数量会意外增加。开发环境有三个代理,生产环境有三十个。没有人更新连接池配置。

第四个假设是坏查询会大声失败。在人工操作系统中,慢查询或错误查询会很快暴露。仪表板加载缓慢,API 超时,工程师运行 EXPLAIN ANALYZE 找到问题。反馈循环很紧。但代理关闭了这个反馈循环。获得慢查询结果的代理只是使用结果。获得空结果集的代理不知道数据是真的不存在还是查询错了。它继续执行任务,可能基于错误读取写入决策。这是一种与应用错误不同的失败类别。异常是可观察的。返回行的语义错误的查询不是。

第五个假设是 Schema 是与工程师的契约。这是大多数团队直到它破裂时才考虑的假设。Schema 是为开发者的人体工程学设计的 —— 命名对工程师有意义,为查询便利而结构化,可空列 “意味某些东西” 只有读原始迁移注释才能知道。当代理可以通过 Text-to-SQL、工具定义或包装数据库的 MCP 服务器看到你的 Schema 时,Schema 变成了与语言模型的契约。列名、表结构和可空性现在影响 LLM 是生成正确的查询还是听起来自信的废话。

防御性工程化方案与关键参数

针对上述假设失效,需要引入一系列防御性工程化方案。以下是可直接落地的参数配置与代码示例。

语句级超时与事务超时

语句超时是第一道防线。人类编写的查询运行 30 秒是一个会被注意到的错误。代理查询运行 30 秒可能是一个没人监视的推理循环。因此,应在角色级别设置超时,而不仅仅是应用级别。

CREATE ROLE agent_worker;
ALTER ROLE agent_worker SET statement_timeout = '5s';
ALTER ROLE agent_worker SET idle_in_transaction_session_timeout = '10s';

idle_in_transaction_session_timeout 特别重要。暂停推理同时持有打开事务的代理可能是一种合法情况,但更常见的是代理卡住或进入无限循环。10 秒的事务内空闲超时可以防止连接长期被占用。

软删除与审计追踪

永远不要让代理硬删除任何东西。对代理可以写入的任何表使用软删除作为基线。

ALTER TABLE orders ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE orders ADD COLUMN deleted_by TEXT;
ALTER TABLE agents ADD COLUMN delete_reason TEXT;

CREATE VIEW active_orders AS
  SELECT * FROM orders WHERE deleted_at IS NULL;

deleted_by 列比看起来更重要。当你调试两小时前发生的事情时,“显示代理 X 删除的所有内容” 是一个你将想要运行的查询。这个字段应当记录具体的代理类型和标识符,例如 agent:customer-support-v2agent:fulfillment-v3

对于风险更高的操作 —— 财务记录、库存变更、用户状态变更 —— 考虑更进一步,使表成为追加日志。代理从不发布 UPDATE 或 DELETE,而是发布带有新状态和原因的 INSERT。

CREATE TABLE order_state_log (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  order_id UUID NOT NULL REFERENCES orders(id),
  previous_status TEXT,
  new_status TEXT NOT NULL,
  changed_by TEXT NOT NULL,
  changed_at TIMESTAMPTZ DEFAULT now(),
  reason TEXT,
  idempotency_key TEXT UNIQUE
);

这是表级的事件溯源模式。一个针对你最敏感实体的单一追加日志表给你完整的审计追踪,使 “撤销” 成为一个投影查询。

幂等性键:重试机制的核心保障

代理会重试,这是设计出来的。每个编排框架至少运行一次传递语义。如果一步失败,它会再次运行。你的写入路径需要为此而设计。

幂等性键是代理包含在每次写入中的稳定标识符。数据库通过唯一约束静默拒绝重复项。代理无论哪种情况都获得成功响应。运行两次操作产生与运行一次相同的结果。

ALTER TABLE order_state_log
 ADD CONSTRAINT uq_idempotency_key UNIQUE (idempotency_key);

在实践中,代理这样构造键:

import hashlib

def make_idempotency_key(task_id: str,
   operation: str, target_id: str) -> str:
    raw = f"{task_id}:{operation}:{target_id}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

任务 ID 来自编排层,在同一逻辑任务的多次重试中保持稳定。这意味着代理可以重试任意多次,而你的数据库每个逻辑操作只看到一次写入。

专用连接池隔离策略

修复方案是为代理工作负载提供专用连接池,与面向人类的交易应用流量独立调整大小。

# 经验法则:(agent_workers 数量 * 平均并发步数 * 0.5)
# 0.5 考虑到大多数代理步涉及 LLM 时间,而非数据库时间

agent_engine = create_engine(
    DATABASE_URL,
    pool_size=10,           # 代理的基础池
    max_overflow=5,         # 突发容量
    pool_timeout=3,         # 快速失败而非无限排队
    pool_recycle=300,       # 每5分钟回收连接
    pool_pre_ping=True,     # 检出前验证连接
    connect_args={
        "options": "-c statement_timeout=5000 -c idle_in_transaction_session_timeout=10000"
    }
)

pool_timeout=3 是经过考虑的。当代理在 3 秒内无法获取连接时,它应该快速失败并重试,而不是在饱和池下无限排队。排队请求是级联失败的方式。

对于运行多个并发代理的系统,在代理和 Postgres 之间添加 PgBouncer。PgBouncer 在事务池模式下运行,这意味着它在每个事务后立即将连接返回到池,而不是为整个会话持有它。这对代理工作负载的有效连接容量是一个显著的倍增器。

# pgbouncer.ini
[databases]
mydb = host=postgres_host dbname=mydb

[pgbouncer]
pool_mode = transaction       # 关键:每个事务后释放连接
max_client_conn = 500         # 客户端(代理)可以连接到此数量
default_pool_size = 20        # 实际 postgres 连接(小得多)
reserve_pool_size = 5         # 紧急容量
reserve_pool_timeout = 1.0    # 储备也耗尽时快速失败

在事务池模式下,20 个实际的 Postgres 连接可以服务 500 个代理连接,因为每个代理只在一个事务的持续时间内持有 Postgres 连接,而不是整个多步任务。

可观测性:查询上下文注入

标准慢查询日志不够。你需要知道哪个代理、哪个任务、哪个推理步产生了查询。在 Postgres 中最实用的方法是查询注释。

from sqlalchemy import text, event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "before_cursor_execute")
def add_agent_context_comment(conn, cursor, statement, parameters, context, executemany):
    agent_ctx = getattr(conn.info, "agent_context", None)
    if agent_ctx:
        statement = f"/* agent_id={agent_ctx['agent_id']}, task_id={agent_ctx['task_id']}, step={agent_ctx['step']} */ {statement}"
    return statement, parameters

# 用法:在执行前在连接上设置上下文
with engine.connect() as conn:
    conn.info["agent_context"] = {
        "agent_id": "fulfillment-v3",
        "task_id": "task-abc-123",
        "step": "check-inventory"
    }
    conn.execute(text("SELECT ..."))

这些注释出现在 pg_stat_activitypg_stat_statements 和你的慢查询日志中。标记为 agent_id=fulfillment-v3, task_id=task-abc-123, step=check-inventory 的慢查询立即可操作。没有这个,你就是在做考古。

构建一个监控视图,按代理聚合查询:

-- 从查询文本中提取代理上下文的 pg_stat_statements
SELECT
  (regexp_match(query, 'agent_id=([^,]+)'))[1] AS agent_id,
  (regexp_match(query, 'task_id=([^,]+)'))[1] AS task_id,
  count(*) AS call_count,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(total_exec_time::numeric, 2) AS total_ms
FROM pg_stat_statements
WHERE query LIKE '%agent_id=%'
GROUP BY 1, 2
ORDER BY total_ms DESC;

当看到单一代理类型占数据库总时间的 60% 时,你知道该去哪里优化。

Schema 可读性:面向代理的设计

-- 大多数 Schema 的样子
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  usr_id UUID,           -- 哪个用户?
  stat_cd INT,           -- 2 什么意思?7 什么意思?
  flg_1 BOOLEAN,         # ???
  upd_ts TIMESTAMPTZ     # 更新时间?但被谁更新?
);

-- 代理可读的 Schema 的样子
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  customer_id UUID NOT NULL REFERENCES customers(id),
  fulfillment_status TEXT NOT NULL CHECK (
    fulfillment_status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')
  ),
  requires_signature BOOLEAN NOT NULL DEFAULT false,
  last_modified_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

第二个 Schema 几乎自动生成正确的 LLM 查询。第一个需要广泛的提示工程来补偿本应在 Schema 层面完成的事情。

对于无法重命名的 Schema(遗留系统、高迁移成本的表),构建一个面向代理的视图层。

CREATE VIEW agent_orders AS
SELECT
  id,
  usr_id         AS customer_id,
  CASE stat_cd
    WHEN 1 THEN 'pending'
    WHEN 2 THEN 'processing'
    WHEN 5 THEN 'shipped'
    WHEN 7 THEN 'delivered'
    WHEN 9 THEN 'cancelled'
  END            AS fulfillment_status,
  flg_1          AS requires_signature,
  upd_ts         AS last_modified_at
FROM orders
WHERE deleted_at IS NULL;

将列注释写成像文档字符串一样 —— 因为对于 Text-to-SQL 代理,它们就是:

COMMENT ON COLUMN agent_orders.fulfillment_status IS
  'Current state of the order in the fulfillment pipeline. '
  'Use this to filter orders that need action: pending and processing orders are active. '
  'Cancelled orders should never be modified.';

COMMENT ON COLUMN agent_orders.requires_signature IS
  'True if the delivery requires an adult signature. '
  'When true, the shipping agent must schedule a delivery window.';

最小权限访问控制

每个代理类型获得自己的数据库角色,在数据库级别定义最小必要权限:

-- 每个代理类型获得自己的角色
CREATE ROLE agent_fulfillment;
CREATE ROLE agent_customer_support;
CREATE ROLE agent_analytics;

-- agent_analytics:只读,只需它需要的表
GRANT SELECT ON agent_orders TO agent_analytics;
GRANT SELECT ON customers TO agent_analytics;
-- 明确:没有 payments、credentials、PII 表的访问权限

-- agent_customer_support:可以更新订单状态,不能触碰财务
GRANT SELECT ON agent_orders TO agent_customer_support;
GRANT INSERT ON order_state_log TO agent_customer_support;
-- 没有 UPDATE on orders —— 变更通过事件日志

-- agent_fulfillment:只能读写shipping相关字段
GRANT SELECT, UPDATE (fulfillment_status, shipped_at, tracking_number)
  ON orders TO agent_fulfillment;

在访问设计审查中要问的问题不是 “这个代理需要什么?” 而是 “如果这个代理的推理出错,或者它的凭证被泄露,最坏的情况是什么?” 在数据库级别降低爆炸半径,在那里它无法被推理绕过。

落地检查清单

将上述方案整合到一起,以下是一个已内化这些失败模式的数据层外观。每个代理类型有自己的数据库角色,具有在数据库级别强制执行的最小必要权限和角色级超时。代理通过专用连接池连接,为代理工作负载模式调整大小,并与面向人类的流量分离。PgBouncer 在事务池模式下运行在代理和 Postgres 之间。

代理可以写入的表使用带有 deleted_by 列的软删除,捕获代理身份。高风险写入路径使用带有幂等键约束的追加日志表。每次写入携带代理 ID 和任务 ID,因此审计追踪始终可遍历。代理可以看到的 Schema 对象为可读性命名,而不是为遗留便利性命名。维护的视图层将遗留列名转换为有意义的名称。列注释写成文档字符串。代理被授予对视图的访问权限,而不是直接访问底层表。

代理发布的每个查询都带有包含代理 ID、任务 ID 和推理步的注释。监控仪表板聚合此数据,因此值班工程师可以实时看到 “代理 X 在过去一小时消耗了 40% 的数据库时间”。断路器已定义:最大写入次数在编排层强制执行,每条语句的最大影响行数通过语句复杂度检查强制执行,最大任务持续时间通过监视过程强制执行,终止停滞的代理会话。

这些模式都不需要新技术。软删除、追加日志、幂等键、最小权限角色、行级安全、查询标记 —— 这些模式已存在多年。代理强制带来的转变是这些模式从 “我们一直想实施的最佳实践” 变为 “承载基础设施”。代理不会给你延迟它们的奢侈。

数据库不是为这个调用方设计的。但让它安全的工具已经在那里了。


资料来源:本文核心观点与代码示例整理自 Arpit Bhayani 的博客文章《Databases Were Not Designed For This》(https://arpitbhayani.me/blogs/defensive-databases)。

systems