读:当 Agent 开始写数据库——六个防御模式
目录
上篇 当 Agent 成为生产调用者 聊了 Agent 打破运维假设的宏观框架。Arpit Bhayani 在 这篇文章 里把镜头拉近到数据库层,逐个拆解传统数据库架构建立在哪些假设之上,以及 Agent 如何把每个假设都踩了一遍。六个防御模式,全是已有技术的重新组合,不需要新工具。
模式一:给 Agent 建专用角色,绑超时
Agent 查数据库没人盯着。一条查询跑了 30 秒,人写出来的代码会有人发现不对劲去排查,Agent 不会,它可能只是在推理循环里卡住了。
PostgreSQL 可以在角色上直接绑超时,不用动应用代码:
CREATE ROLE agent_worker; ALTER ROLE agent_worker SET statement_timeout = '5s'; ALTER ROLE agent_worker SET idle_in_transaction_session_timeout = '10s';
statement_timeout 管单条语句的执行上限,超了自动取消。 idle_in_transaction_session_timeout 管事务开启后的空闲等待上限。Agent 经常在事务中间停下来等 LLM 返回结果,这个参数就是治这个毛病的:思考可以,别占着事务连接不放。
超时绑在角色上而不是应用上,好处是同一个数据库里人走普通角色、Agent 走 agent_worker 角色,各管各的超时,互不干扰。
模式二:软删除 + 操作者追踪
Agent 会删数据,而且可能因为推理出错、重试或者误解任务而删掉不该删的。人删数据好歹是有意为之,出问题找得到人;Agent 删数据是自主决策的结果,你没法事后问它"你当时怎么想的"。
所以不能让它硬删。加三列标记删除:
ALTER TABLE orders ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE orders ADD COLUMN deleted_by TEXT; ALTER TABLE orders ADD COLUMN delete_reason TEXT;
再建一个视图给 Agent 用:
CREATE VIEW active_orders AS SELECT * FROM orders WHERE deleted_at IS NULL;
Agent 查 active_orders ,看不到已删除的行,也恢复不了。
deleted_by 这一列比另外两列都重要。出事后两小时你要查"Agent X 到底删了什么",靠的就是它:
SELECT * FROM orders WHERE deleted_by = 'agent:customer-support-v2' ORDER BY deleted_at DESC;
值用 agent:角色名 和 user:用户ID 两种前缀区分来源,排查时一眼分清是人干的还是 Agent 干的。
模式三:幂等键
Agent 天生会重试。所有编排框架都是"至少执行一次"的语义:步骤失败了重跑,网络抖动让它以为没成功就再来一遍。写入路径不做幂等保护,重试一次就多一行脏数据。
幂等键的原理不复杂:Agent 每次写入带一个稳定标识符,数据库靠唯一约束拒绝重复,重试多少次都只留一条记录。
先建一张带幂等键的状态变更表:
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 );
键从编排层的 task_id + 操作类型 + 目标 ID 组合生成,同一个逻辑操作不管重试几次都出同一个键。一行Shell 就能算:
echo -n "task-abc-123:update_status:order-456" | sha256sum | cut -c1-32
c72a5000a3e5135734decc054b677e3b
Agent 第一次写入成功,第二次重试时 UNIQUE 约束静默拒绝,响应照样返回成功。数据库里始终只有一条记录,不会因为重试而膨胀。
这种"只追加不修改"的思路跟 Event Sourcing 同源,只不过 Event Sourcing 是整条链路只追加,这里是单表级别:每条状态变更都是一行新记录,"撤销"就是再插一行,不走 UPDATE 或 DELETE。
模式四:独立连接池 + PgBouncer
Agent 连数据库的方式跟人不一样,连的久、连的多、数量不可控。
连的久:Agent 发一条查询,停下来等 LLM 推理,再发一条,再等。连接占用时间拉长到查询时间 + LLM 推理时间 x 推理步数,比普通请求长一个数量级。
连的多:一个高层任务可能生成多个子 Agent 并行干活,一个任务变五个连接。
数量不可控:开发时 3 个 Agent,上线时变 30 个,没人想起来更新连接池配置。
所以 Agent 要用独立的连接池,跟人工流量分开:
# 连接池配置示意(关键参数) pool_size = 10, # Agent 基础池大小 max_overflow = 5, # 突发容量 pool_timeout = 3, # 3 秒拿不到连接就失败,不排队 pool_recycle = 300, # 5 分钟回收连接
pool_timeout 设为 3 秒是故意的。拿不到连接就快速失败、带退避重试,别在队列里等着。排队等待是级联故障的经典起点。
Agent 数量多了以后,在 Agent 和 PostgreSQL 之间加一层 PgBouncer(连接池代理),设为事务级池化:
# pgbouncer.ini pool_mode = transaction # 事务一结束就归还连接 max_client_conn = 500 # Agent 端可以连 500 个 default_pool_size = 20 # 实际 PostgreSQL 连接只要 20 个 reserve_pool_size = 5 # 应急容量
事务级池化的意思是:Agent 的多步任务期间一直占着连接,但每个事务一提交,PgBouncer 就把底层 PostgreSQL 连接转给别的 Agent 用。20 个真实连接就能服务 500 个 Agent 连接。
模式五:查询注释监控
Agent 出的 bug 跟普通应用 bug 不一样。普通应用报错,日志里有堆栈,工程师能查。Agent 的错误是语义级的:查询语法正确,返回了结果,但结果本身是错的。日志里完全看不出来。Agent 拿到慢查询结果照用不误,拿到空结果集不知道是数据真的不存在还是查询写错了,继续往下走,可能拿错误数据做决策。
解法是给每条 Agent 查询打标签,标明是哪个 Agent、哪个任务、哪一步发出的:
/* agent_id=fulfillment-v3, task_id=task-abc-123, step=check-inventory */ SELECT * FROM orders WHERE fulfillment_status = 'pending';
注释会出现在 pg_stat_activity 、 pg_stat_statements 和慢查询日志里。 pg_stat_statements 是 PostgreSQL 扩展,需要先启用( CREATE EXTENSION pg_stat_statements )才能查。按 Agent 分组的监控视图:
SELECT (regexp_match(query, 'agent_id=([^,]+)'))[1] AS agent_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 ORDER BY total_ms DESC;
某个 Agent 占了 60% 的数据库时间,马上能定位到。没注释的话, pg_stat_statements 里一堆 SQL,只能猜"这大概是哪个 Agent 发的"。
模式六:语义化 Schema + 视图层
Agent 通过 Text-to-SQL、MCP 工具或直接读 schema 来了解表结构。列名叫什么,直接决定 LLM 生成的 SQL 对不对。
看两组对比就明白了:
-- 缩写式命名(人能看懂,LLM 容易猜错) CREATE TABLE orders ( id UUID PRIMARY KEY, usr_id UUID, -- 哪个用户? stat_cd INT, -- 2 是什么意思?7 呢? flg_1 BOOLEAN, -- 这是啥? upd_ts TIMESTAMPTZ -- 谁更新的? );
-- 语义化命名(LLM 几乎不会猜错) 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() );
第二套里 fulfillment_status 一看就知道是订单履行状态, CHECK 约束把合法值列全了,LLM 不用猜。第一套里 stat_cd 是个状态码,2 是什么 7 是什么,LLM 只能蒙。
老表改名迁移成本太高,可以在上面盖一层视图:
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; -- Agent 只看有效行
Agent 只查视图,不碰底层表。视图把缩写翻成语义化名字,把状态码翻成文字。再给列加注释,等于给 LLM 写参考手册:
COMMENT ON COLUMN agent_orders.fulfillment_status IS '订单在履行流程中的当前状态。用这个字段筛选需要操作的订单:' 'pending 和 processing 是活跃订单。' 'cancelled 订单不应被修改。';
注释写得好,Text-to-SQL 生成的查询就准。
小结
六个模式全靠 PostgreSQL 已有能力就能实现:角色参数、软删除、唯一约束、连接池、查询注释、视图和列注释。不用上新工具、不用换新框架。变的只是默认配置的态度:以前这些是"锦上添花"的活,Agent 时代变成了"雪中送炭"的事。