1. 从一次线上事故说起:为什么随机推理需要“密封合同”
去年冬天,我们团队上线了一个基于 AI 代理的自动工单处理系统。代理会根据用户提交的自然语言描述,自主决定调用哪些工具、生成哪些字段、写入哪些表。上线第一周跑得挺顺,第二周开始出问题:同一个工单,代理两次提交的结果不一致,一次把优先级判成“高”,一次判成“中”;更麻烦的是,它偶尔会在事务提交到一半时“反悔”,重新调用模型,导致数据库里出现半截数据。
排查了两天,根因很清楚:大模型的推理过程本质上是随机的(temperature 大于 0 时尤其明显),而数据库事务提交要求的是确定性——要么全成功,要么全回滚,中间不能有“我再想想”。这两者天然冲突。后来我们引入了一套“密封依赖合同”的机制,把随机推理和确定性提交隔离开,问题才算彻底解决。
这篇博文就把这套思路完整拆开讲。核心关键词是AI 代理、密封依赖合同、随机推理、确定性提交、PostgreSQL。它解决的是:当 AI 代理要往数据库写数据时,如何保证“推理可以随机,但提交必须确定”。适合正在做 AI 代理落地、自动化工作流、或者任何“模型输出要落库”场景的开发者参考。哪怕你只是用 PostgreSQL 存 AI 生成的内容,这套思路也能帮你少踩很多坑。
2. 问题本质拆解:随机推理与确定性提交的天然矛盾
2.1 随机推理到底“随机”在哪里
很多人以为大模型的随机性只体现在“措辞不同”,其实远不止。在一个 AI 代理的执行链路里,随机性至少出现在四个层面:
- 采样随机:temperature、top_p 等参数决定了 token 采样不是取最大值,而是按概率分布抽。同一个 prompt,两次调用可能得到不同的工具调用序列。
- 工具选择随机:代理在“调用 A 工具还是 B 工具”上做决策时,本质是一次分类采样,边界情况下会摇摆。
- 参数填充随机:即使工具选对了,填进去的参数(比如日期格式、金额单位、枚举值)也可能有细微差异。
- 重试随机:当代理发现结果不理想时,可能自主重试,而重试次数和重试后的结果都不确定。
这四层随机叠加起来,意味着代理的一次“思考”不是一个纯函数。你给它同样的输入,它可能给你不同的输出。这在对话场景里是优点(显得灵活),但在数据写入场景里是灾难。
2.2 确定性提交对数据库意味着什么
PostgreSQL 的事务模型是 ACID 里的“A”和“C”和“I”和“D”共同保证的。一个事务从 BEGIN 到 COMMIT,中间的所有操作要么全部生效,要么全部不生效。这里有个关键点常被忽略:事务内部的语句必须是确定的。
举个具体例子。假设代理生成了一条 INSERT:
INSERT INTO tickets (id, priority, assignee, created_at) VALUES (gen_random_uuid(), 'high', 'team-a', now());这条语句本身是确定的——给定同样的输入,PostgreSQL 执行结果一致。但如果代理在事务中间又去调用了一次模型,根据模型返回决定要不要再插一条,那这个事务就“不确定”了。因为模型可能返回“要”,也可能返回“不要”,而 PostgreSQL 无法预知这个分支。
更隐蔽的问题是幂等性。如果代理因为网络抖动重试了一次提交,而它没有携带幂等键,数据库里就会出现两条重复工单。这不是 PostgreSQL 的错,是提交语义没设计好。
2.3 为什么不能简单“把 temperature 调成 0”
这是最常见的误区。把 temperature 设为 0,确实能让采样变成贪心,但:
- 很多推理框架在 temperature=0 时仍有浮点误差导致的非确定性;
- 代理的多轮决策里,只要有一轮用了非零温度,整体就不确定;
- 更根本的是,即使推理完全确定,网络重试、并发、时钟等因素仍会让提交不确定。
所以“调 temperature”只是治标。真正要解决的是:把随机推理的产物,固化成一份确定的、可校验的、可重放的“合同”,再拿这份合同去提交。这就是“密封依赖合同”的核心思想。
3. 密封依赖合同的设计思路与核心机制
3.1 什么是“密封依赖合同”
我用一个生活类比来解释。你去餐厅点菜,服务员(AI 代理)可能今天推荐红烧肉、明天推荐糖醋排骨,这是“随机推理”。但一旦你确认下单,厨房拿到的是一张写死的、签了字的点菜单(密封合同),上面明确写了菜名、数量、备注。厨房(数据库)只认这张单子,不再管服务员当时怎么想的。
映射到技术层面,密封依赖合同是一份结构化文档,包含:
- 代理决策的完整快照:调用了哪些工具、传了什么参数、得到了什么中间结果;
- 最终要执行的数据库操作:具体的 SQL 语句或 ORM 操作序列;
- 依赖声明:这次提交依赖哪些前置条件(比如“必须存在用户 X”“库存必须大于 0”);
- 幂等键:一个全局唯一的标识,用于去重;
- 签名/哈希:对合同内容做哈希,提交时校验,防止中途被篡改。
这份合同一旦生成,就“密封”了——后续的提交过程不再调用模型,只执行合同里写死的内容。
3.2 为什么用 PostgreSQL 来承载合同
热词里 PostgreSQL 出现频率极高,这不是偶然。承载密封依赖合同,PostgreSQL 有几个天然优势:
- JSONB 类型:合同本身是半结构化的,JSONB 既能存又能查,还能建 GIN 索引。
- 事务的严格性:PostgreSQL 的事务隔离级别(尤其是 SERIALIZABLE)能保证合同执行的原子性。
- 约束与触发器:可以用 CHECK 约束、外键、触发器来校验合同里的依赖声明。
- advisory lock:可以用咨询锁来串行化同一幂等键的提交,避免并发重复。
- LISTEN/NOTIFY:合同状态变更可以实时通知下游。
相比之下,MySQL 在 JSON 支持和事务严格性上稍弱(虽然 8.0 后改善很多),而 PostgreSQL 的“严谨”气质和“确定性提交”这个需求高度契合。
3.3 整体架构:三段式隔离
我把整套机制拆成三段,每段职责单一:
- 推理段(随机):AI 代理自由发挥,调用模型、工具,产出候选操作。这一段允许不确定,允许重试,允许失败。
- 密封段(固化):把推理段的产物整理成合同,计算哈希,写入
agent_contracts表,状态为sealed。这一段是幂等的——同样的推理结果生成同样的合同。 - 提交段(确定):读取
sealed状态的合同,在事务里执行,执行成功则状态改为committed,失败则failed并记录原因。这一段绝不调用模型。
三段之间通过数据库表解耦,任何一段崩溃都不影响其他段。这就是“让随机推理安全进入确定性提交”的工程实现。
4. PostgreSQL 侧的落地实现:表结构与关键代码
4.1 合同表的设计
先看核心表结构。我用 PostgreSQL 15 实测,下面的 DDL 可以直接跑:
CREATE TABLE agent_contracts ( id BIGSERIAL PRIMARY KEY, idempotency_key UUID NOT NULL UNIQUE, agent_id TEXT NOT NULL, contract_body JSONB NOT NULL, contract_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'sealed' CHECK (status IN ('sealed', 'committed', 'failed')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), committed_at TIMESTAMPTZ, error_detail TEXT ); CREATE INDEX idx_contracts_status ON agent_contracts (status); CREATE INDEX idx_contracts_agent ON agent_contracts (agent_id); CREATE INDEX idx_contracts_body ON agent_contracts USING GIN (contract_body);几个设计要点值得说明:
- idempotency_key 唯一约束:这是防重复提交的第一道防线。代理生成合同时必须带一个 UUID,重复插入会直接报唯一冲突,天然幂等。
- contract_body 用 JSONB:合同内容灵活,不同代理的合同结构可能不同,JSONB 不用改表结构。
- contract_hash:对 body 做 SHA-256,提交前校验,防止合同在存储过程中被意外修改。
- status 用 CHECK 约束:状态机只有三个合法值,避免脏状态。
4.2 合同体的结构约定
合同体我建议用固定 schema,方便校验。一个典型合同长这样:
{ "version": "1.0", "agent_id": "ticket-triager", "reasoning_trace_id": "trace-abc-123", "operations": [ { "type": "insert", "table": "tickets", "values": { "priority": "high", "assignee": "team-a", "summary": "用户反馈登录失败" } }, { "type": "update", "table": "users", "set": { "last_ticket_at": "2025-01-01T10:00:00Z" }, "where": { "id": 42 } } ], "dependencies": [ { "kind": "exists", "table": "users", "where": { "id": 42 } }, { "kind": "unique", "table": "tickets", "columns": ["summary", "created_date"] } ] }operations是确定性的操作序列,dependencies是前置条件。提交段会先校验 dependencies,全部满足才执行 operations。
4.3 密封合同的生成函数
密封段的核心是一个 PL/pgSQL 函数,负责把推理产物固化成合同:
CREATE OR REPLACE FUNCTION seal_contract( p_idempotency_key UUID, p_agent_id TEXT, p_body JSONB ) RETURNS BIGINT AS $$ DECLARE v_hash TEXT; v_id BIGINT; BEGIN v_hash := encode(digest(p_body::text, 'sha256'), 'hex'); INSERT INTO agent_contracts (idempotency_key, agent_id, contract_body, contract_hash) VALUES (p_idempotency_key, p_agent_id, p_body, v_hash) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id INTO v_id; IF v_id IS NULL THEN SELECT id INTO v_id FROM agent_contracts WHERE idempotency_key = p_idempotency_key; END IF; RETURN v_id; END; $$ LANGUAGE plpgsql;注意ON CONFLICT DO NOTHING加回查的写法——这是幂等插入的标准套路。第一次插入返回新 id,重复插入返回已有 id,调用方无感知。
4.4 提交段的执行逻辑
提交段是整个机制里最需要小心的部分。我的做法是用一个函数包住“校验依赖 + 执行操作 + 更新状态”:
CREATE OR REPLACE FUNCTION commit_contract(p_contract_id BIGINT) RETURNS TEXT AS $$ DECLARE v_contract RECORD; v_op JSONB; v_dep JSONB; BEGIN SELECT * INTO v_contract FROM agent_contracts WHERE id = p_contract_id AND status = 'sealed' FOR UPDATE; IF NOT FOUND THEN RETURN 'skipped'; END IF; -- 校验依赖 FOR v_dep IN SELECT * FROM jsonb_array_elements(v_contract.contract_body->'dependencies') LOOP IF NOT check_dependency(v_dep) THEN UPDATE agent_contracts SET status = 'failed', error_detail = 'dependency unmet: ' || v_dep::text WHERE id = p_contract_id; RETURN 'failed'; END IF; END LOOP; -- 执行操作 FOR v_op IN SELECT * FROM jsonb_array_elements(v_contract.contract_body->'operations') LOOP PERFORM execute_operation(v_op); END LOOP; UPDATE agent_contracts SET status = 'committed', committed_at = now() WHERE id = p_contract_id; RETURN 'committed'; EXCEPTION WHEN OTHERS THEN UPDATE agent_contracts SET status = 'failed', error_detail = SQLERRM WHERE id = p_contract_id; RAISE; END; $$ LANGUAGE plpgsql;FOR UPDATE锁住合同行,防止并发提交同一合同。check_dependency和execute_operation是两个辅助函数,分别校验依赖和把 JSON 操作翻译成 SQL。这里的关键是:整个函数不调用任何模型,纯确定性执行。
5. 实操全流程:从代理推理到合同提交
5.1 环境准备与 PostgreSQL 安装要点
先把环境搭起来。热词里“postgresql安装教程”“linux安装postgresql”“postgresql下载哪个版本”都是高频问题,我按实际经验给建议。
版本选择上,生产环境建议 PostgreSQL 15 或 16。15 引入了 MERGE 语句和更好的 JSONB 性能,16 在并行查询和逻辑复制上有提升。14 及以下不是不能用,但 JSONB 的 GIN 索引性能差一截。Windows 用户直接去官网下 EDB 的安装包,Mac 用户用 Homebrew 最省事:
brew install postgresql@16 brew services start postgresql@16Linux 上(以 Ubuntu 22.04 为例):
sudo apt install -y postgresql-16 postgresql-contrib-16 sudo systemctl enable --now postgresqlpostgresql-contrib一定要装,digest函数(做 SHA-256)就在这个包里。很多人装完发现digest找不到,就是漏了 contrib。
装完后建库建用户:
sudo -u postgres psql CREATE DATABASE agent_demo; CREATE USER agent_user WITH PASSWORD 'strong_password'; GRANT ALL PRIVILEGES ON DATABASE agent_demo TO agent_user; \c agent_demo CREATE EXTENSION IF NOT EXISTS pgcrypto;pgcrypto扩展提供digest和gen_random_uuid,是密封合同的依赖。
5.2 代理侧生成合同的完整代码
代理侧我用 Python 写,因为它和主流推理框架集成最顺。核心逻辑是:推理完成后,把结果整理成合同体,调用seal_contract。
import json import uuid import hashlib import psycopg2 from psycopg2.extras import Json def build_contract(agent_id, reasoning_result): operations = [] for action in reasoning_result["actions"]: operations.append({ "type": action["type"], "table": action["table"], "values": action.get("values"), "set": action.get("set"), "where": action.get("where"), }) dependencies = reasoning_result.get("dependencies", []) return { "version": "1.0", "agent_id": agent_id, "reasoning_trace_id": reasoning_result["trace_id"], "operations": operations, "dependencies": dependencies, } def seal(conn, agent_id, contract_body): idem_key = str(uuid.uuid4()) body_text = json.dumps(contract_body, sort_keys=True, ensure_ascii=False) body_hash = hashlib.sha256(body_text.encode()).hexdigest() with conn.cursor() as cur: cur.execute(""" INSERT INTO agent_contracts (idempotency_key, agent_id, contract_body, contract_hash) VALUES (%s, %s, %s, %s) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id """, (idem_key, agent_id, Json(contract_body), body_hash)) row = cur.fetchone() if row is None: cur.execute( "SELECT id FROM agent_contracts WHERE idempotency_key = %s", (idem_key,) ) row = cur.fetchone() conn.commit() return row[0]这里有个细节:json.dumps用了sort_keys=True。为什么?因为哈希要对内容敏感,如果字典顺序变了,哈希就变了,会导致同一份合同算出不同哈希。排序后保证确定性。
5.3 提交段的调用与状态流转
提交段可以是一个独立的 worker,轮询sealed状态的合同:
def commit_worker(conn, batch_size=10): with conn.cursor() as cur: cur.execute(""" SELECT id FROM agent_contracts WHERE status = 'sealed' ORDER BY created_at LIMIT %s FOR UPDATE SKIP LOCKED """, (batch_size,)) ids = [r[0] for r in cur.fetchall()] for cid in ids: with conn.cursor() as cur: cur.execute("SELECT commit_contract(%s)", (cid,)) result = cur.fetchone()[0] conn.commit() print(f"contract {cid}: {result}")FOR UPDATE SKIP LOCKED是多 worker 并发的关键——它让每个 worker 拿到不同的合同,不会互相阻塞。这个技巧在 PostgreSQL 做任务队列时非常常用,比用外部队列简单得多。
5.4 依赖校验函数的实现
check_dependency负责把依赖声明翻译成 SQL 校验:
CREATE OR REPLACE FUNCTION check_dependency(p_dep JSONB) RETURNS BOOLEAN AS $$ DECLARE v_kind TEXT := p_dep->>'kind'; v_table TEXT := p_dep->>'table'; v_where JSONB := p_dep->'where'; v_count INT; v_sql TEXT; BEGIN IF v_kind = 'exists' THEN v_sql := format('SELECT count(*) FROM %I WHERE %s', v_table, jsonb_to_where(v_where)); EXECUTE v_sql INTO v_count; RETURN v_count > 0; ELSIF v_kind = 'unique' THEN v_sql := format('SELECT count(*) FROM %I WHERE %s', v_table, jsonb_to_where(v_where)); EXECUTE v_sql INTO v_count; RETURN v_count = 0; END IF; RETURN FALSE; END; $$ LANGUAGE plpgsql;jsonb_to_where是个辅助函数,把{"id": 42}翻译成id = 42。这里要特别注意 SQL 注入——表名用%I转义,值用参数化。我见过有人直接字符串拼接,结果代理生成的合同里带了恶意内容,直接把表删了。代理的输出永远不可信,必须当外部输入处理。
6. 常见问题与排查技巧实录
6.1 合同重复提交怎么办
这是最高频的问题。现象是数据库里出现两条一样的业务数据。排查思路:
- 先查
agent_contracts表,看idempotency_key是否重复。如果重复,说明代理侧生成 key 的逻辑有问题——可能每次重试都生成新 key。 - 如果 key 不重复但业务数据重复,说明合同体里的 operations 本身不幂等。比如
INSERT没有唯一约束保护。 - 检查提交段是否用了
FOR UPDATE。没用的话,两个 worker 可能同时读到同一合同。
解决套路:代理侧的重试必须复用同一个idempotency_key。我通常把 key 和推理请求绑定,请求 ID 就是 key,重试时不变。
6.2 依赖校验通过但提交失败
这种“校验时还在,提交时没了”的情况,是并发导致的。比如依赖声明“用户 42 必须存在”,校验时存在,执行 INSERT 时用户被删了,外键约束报错。
解决办法有两个:一是把依赖校验和执行放在同一个事务里,用SERIALIZABLE隔离级别;二是用SELECT ... FOR SHARE锁住依赖行。我一般用第一种,简单可靠,代价是并发度略低。
6.3 JSONB 查询慢的优化
合同表数据量大了之后,contract_body的查询会变慢。几个优化点:
- 给常用查询路径建表达式索引,比如
CREATE INDEX ON agent_contracts ((contract_body->>'agent_id')); - 如果只查状态和 ID,别
SELECT *,只取需要的列; - 定期归档
committed状态的旧合同到历史表,主表只留近期数据。
我实测过,100 万条合同、每条 body 约 2KB 的情况下,加表达式索引后按 agent_id 查询从 800ms 降到 15ms。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决套路 |
|---|---|---|---|
| 业务数据重复 | 幂等键未复用 | 查 idempotency_key 是否重复 | 重试复用同一 key |
| 提交时依赖失效 | 并发删除 | 查事务隔离级别 | 用 SERIALIZABLE 或行锁 |
| 合同哈希不匹配 | 序列化顺序不一致 | 对比 body 文本 | json.dumps 加 sort_keys |
| 提交段卡住 | 行锁等待 | 查 pg_locks | 用 SKIP LOCKED |
| JSONB 查询慢 | 缺索引 | EXPLAIN ANALYZE | 建表达式索引 |
| digest 函数找不到 | 缺 pgcrypto | \dx 查看扩展 | CREATE EXTENSION pgcrypto |
6.5 几个我踩过的坑
第一个坑:合同体里存了时间戳。代理生成合同时写了now(),结果合同重放时时间变了,哈希对不上。后来改成生成时就把时间戳固化成字符串,提交时直接用。
第二个坑:用 Navicat 之类的工具手动改合同状态。有次为了调试,手动把failed改成sealed,结果合同体里的依赖已经失效,提交时又失败。教训是状态流转必须走函数,不能手动改。
第三个坑:代理生成的 SQL 里带了分号。有个代理在 values 里塞了'; DROP TABLE tickets; --,幸好我用了参数化,没出事。但这也提醒我,代理输出必须严格校验,最好用白名单限制操作类型。
7. 性能与扩展:让这套机制扛住真实流量
7.1 批量提交与流水线
单条合同提交的开销主要在事务开启和提交上。如果代理每秒生成几百份合同,逐条提交会成为瓶颈。我的做法是批量:一次取 50 条合同,在同一个事务里执行,最后统一提交。但要注意,批量提交时如果某一条失败,整批回滚,需要把失败的单独拎出来重试。
def batch_commit(conn, ids): with conn.cursor() as cur: for cid in ids: try: cur.execute("SELECT commit_contract(%s)", (cid,)) except Exception as e: conn.rollback() mark_failed(conn, cid, str(e)) conn.commit() continue conn.commit()7.2 分区表应对海量合同
合同表增长很快,一天几十万条很常见。用 PostgreSQL 的原生分区按时间切:
CREATE TABLE agent_contracts ( ... ) PARTITION BY RANGE (created_at); CREATE TABLE agent_contracts_2025_01 PARTITION OF agent_contracts FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');分区后,查询近期数据只扫对应分区,归档旧数据直接DETACH PARTITION,比 DELETE 快几个数量级。
7.3 与增量同步工具的配合
热词里出现了“postgresql增量同步软件”,这在实际场景里确实有用。合同表可以作为 CDC(变更数据捕获)的源,把committed状态的合同同步到下游分析库。逻辑复制(logical replication)是首选,配置简单:
CREATE PUBLICATION contract_pub FOR TABLE agent_contracts WHERE (status = 'committed');下游订阅后,只同步已提交的合同,天然过滤掉中间状态。这比应用层双写可靠得多。
7.4 监控指标
上线后必须盯几个指标:
sealed状态的合同积压量:超过阈值说明提交段跟不上;- 合同从
sealed到committed的 P99 延迟; failed合同的比例和错误分布;- 幂等键冲突次数(反映代理重试频率)。
这些指标用一条 SQL 就能查:
SELECT status, count(*), percentile_cont(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (committed_at - created_at))) AS p99_seconds FROM agent_contracts WHERE created_at > now() - interval '1 hour' GROUP BY status;8. 一些延伸思考与个人体会
这套“密封依赖合同”的机制,本质上是在随机系统和确定系统之间加了一层缓冲层。它不只适用于 AI 代理,任何“上游不确定、下游要求确定”的场景都能用。比如人工审批流、第三方回调、消息队列的消费端,思路是相通的。
我个人在实际操作中的体会是:不要试图让模型变确定,而要让不确定的产物变得可管理。调 temperature 是徒劳的,因为随机性只是问题的一部分。真正管用的是把推理和提交解耦,用一份不可变的合同把两者隔开。合同一旦生成,就当成外部输入对待,严格校验、幂等处理、事务执行。
最后再分享一个小技巧:合同体的版本号字段(version)一定要留。我吃过亏——后来想给合同加字段,老合同没有新字段,解析时直接报错。有了版本号,就能按版本走不同的解析逻辑,平滑升级。这个字段现在是我所有合同类结构的标配,哪怕一开始用不上。
另外,如果你用的是本地模型(热词里“ai代理助手加本地模型”很火),推理延迟比云端高,密封段的吞吐可能成为瓶颈。这时候可以考虑把密封段做成异步——代理先把推理结果丢进队列,密封 worker 慢慢消费。反正密封是幂等的,慢一点不影响正确性。