让Agent生成一份订单核对表:每单的商品金额是多少,已付款多少。它连接了订单、商品明细和付款记录,按订单分组,查询成功,最后还交出一张整齐的表。
问题是,一笔实际商品金额与已付款都为200的订单,被写成了600和400。原表没有重复记录,每个明细ID也都唯一。
错误不在加法,而在加法之前:一次关联把同一笔事实展开了多次。 对接数据库工具的Agent来说,“SQL能执行”和“报表数字具有正确含义”之间,还隔着一层数据粒度验收。
AI原创概念插画:红漆托盘上有两只琥珀色玻璃梨和蓝色玻璃蜗牛,后方波纹屏风中出现多处梨形影像。借实物与重复显影作艺术隐喻,不是精确光学演示,也不表示数据库执行过程。
先回答一个比“用哪个函数”更早的问题
本篇的“粒度”,指的是:一行代表什么,以及用什么键识别这一件事。
下面是自造的教学数据,不是本站订单或已发现的线上故障。金额统一采用整数教学单位,不涉及币种转换、税费或舍入。
| 表 | 一行代表什么 | 示例 |
|---|---|---|
| orders | 一笔订单 | 订单1 |
| lines | 一条商品明细 | 明细11金额100;明细12金额100 |
| payments | 一笔分配给订单的付款 | 付款21金额50;22金额70;23金额80 |
商品明细有两条,付款有三笔;它们都属于订单1,但没有定义某条明细与某笔付款一一对应。两条同额明细是两件真实业务事实,不是脏数据。
如果只以订单ID把两组明细接到一起,得到的是六种明细—付款组合:每条商品明细都匹配三笔付款,每笔付款都匹配两条商品明细。
PostgreSQL 18的连接文档明确描述了这种规则:对左侧一行,右侧每一个满足条件的匹配行都会产生一行连接结果;之后才对这些输入行分组、聚合。[1] 这讲的是结果语义,不是要求数据库在物理上一定先物化一张六行临时表。
因此,GROUP BY order_id不会自动识别“这个金额以前算过了”。它只把已经展开的输入归到同一订单组,再按你写的聚合表达式计算。
本地跑一次:没有重复主键,仍然重复计费
下面完整代码已在Python 3.13.5、SQLite 3.53.4上实际运行。数据库只在内存中,全部数据人工构造;没有调用模型、Agent、PostgreSQL或真实订单服务。
订单2刻意没有明细和付款,用于观察左连接与空值的边界。
import sqlite3
c = sqlite3.connect(':memory:')
c.executescript('''
CREATE TABLE orders(id INTEGER PRIMARY KEY);
CREATE TABLE lines(id INTEGER PRIMARY KEY, order_id INTEGER, amount INTEGER NOT NULL);
CREATE TABLE payments(id INTEGER PRIMARY KEY, order_id INTEGER, amount INTEGER NOT NULL);
INSERT INTO orders VALUES (1), (2);
INSERT INTO lines VALUES (11,1,100), (12,1,100);
INSERT INTO payments VALUES (21,1,50), (22,1,70), (23,1,80);
''')
join_sql = '''FROM orders o
LEFT JOIN lines l ON l.order_id = o.id
LEFT JOIN payments p ON p.order_id = o.id'''
print('pairs:', c.execute('SELECT l.id, p.id ' + join_sql +
' WHERE o.id = 1 ORDER BY l.id, p.id').fetchall())
print('naive:', c.execute('SELECT o.id, SUM(l.amount), SUM(p.amount) ' +
join_sql + ' GROUP BY o.id ORDER BY o.id').fetchall())
print('distinct:', c.execute('SELECT SUM(DISTINCT l.amount), SUM(DISTINCT p.amount) ' +
join_sql + ' WHERE o.id = 1').fetchone())
fixed_sql = '''
WITH l AS (
SELECT order_id, SUM(amount) AS total FROM lines GROUP BY order_id
), p AS (
SELECT order_id, SUM(amount) AS total FROM payments GROUP BY order_id
)
SELECT o.id, COALESCE(l.total, 0), COALESCE(p.total, 0)
FROM orders o
LEFT JOIN l ON l.order_id = o.id
LEFT JOIN p ON p.order_id = o.id
ORDER BY o.id
'''
print('fixed:', c.execute(fixed_sql).fetchall())
print('empty counts:', c.execute('SELECT COUNT(*), COUNT(l.id), COUNT(DISTINCT l.id) ' +
join_sql + ' WHERE o.id = 2').fetchone())
c.close()实际输出:
pairs: [(11, 21), (11, 22), (11, 23), (12, 21), (12, 22), (12, 23)]
naive: [(1, 600, 400), (2, None, None)]
distinct: (100, 200)
fixed: [(1, 200, 200), (2, 0, 0)]
empty counts: (1, 0, 0)第一行列出了六个不同的ID组合。它们没有任何一对完全重复,所以这里甚至不是“把完全相同的结果行去掉”就能解决的问题。
naive里,商品金额600来自两条100各被计算三次;付款400来自三笔付款各被计算两次。订单2虽然被左连接保留下来,SUM没有非空输入,返回的是None,对应SQL的NULL。[2][3]
fixed才回到了预期:订单1的两个总额都是200,订单2按本例规定显示为0。
这个小实验只验证合成数据上的SQL语义,不是Agent错误率或数据库性能基准。
为什么加上DISTINCT,反而可能少算
看到重复后,很容易把表达式改为:SUM(DISTINCT l.amount)。
但DISTINCT作用在这个聚合的输入值上,不是自动按业务记录ID去重。SQLite文档明确说明,单参数聚合中的DISTINCT会先过滤重复元素,再交给聚合函数。[3]
两条商品明细的金额都是100,于是只剩一个100参与求和。你消除了连接带来的重复,也把两条合法同额明细合并了。
输出中的distinct: (100, 200)很有迷惑性:付款总额碰巧正确,因为三笔付款的金额各不相同;商品总额却从多算变成少算。在这一份数据上修好一列,不等于修好了查询。 若付款中也有两笔同额付款,另一列同样有风险。
COUNT(DISTINCT l.id)可以回答“出现了多少个不同明细ID”,但这个正确计数不能顺带证明SUM(l.amount)也正确。聚合表达式各有自己的计量对象,不能共享一个笼统的“已去重”标签。
把SELECT DISTINCT放在已聚合结果外面也一样:它可能去掉重复输出行,却不会撤回已经加入总额的重复贡献。
先收拢到目标粒度,再把结果并排放好
代码里的修正分三步:
- 商品明细按订单汇总,得到每单至多一行商品总额。
- 付款按订单汇总,得到每单至多一行付款总额。
- 把这两份订单级结果连接到订单表。
这时接在一起的是“订单级摘要”,而不是两份互相独立的明细集合。避免膨胀的理由是每个连接键在各摘要中至多出现一次,不是因为用了WITH这个关键字。等价子查询也可以表达相同的粒度边界。
PostgreSQL邮件列表2016年的一则原始讨论就展示过销售与补货连接后重复统计的问题,并采用分别聚合后再连接的方式。[4] 它是历史工程案例,不是新功能公告;本文机制同时由已读的连接和聚合文档、以及本轮SQLite实验核对,不沿用旧讨论中的性能猜测。
这个方法有明确适用条件:你要的是每单两项各自的总额。如果问题变成“每笔付款具体分摊给了哪些商品”,就需要真实的付款—明细分配关系;提前汇总会丢掉那份问题所需的细节,不能拿订单ID的全组合冒充分摊。
同样,只汇总到订单ID也不总是够。若订单ID只在租户内唯一,实际关联键与聚合键都要包含可信租户;若报表要求每单每天一行,各路摘要就要按同一明确日期口径对齐。粒度是业务契约,不只是SQL末尾的一串列名。
两个常被忽略的边界:存在性和空集合
只想判断有没有,不必把所有匹配明细都接进来。
例如,任务是“统计至少有一笔付款的订单中的商品金额”,付款仅用于资格判断,不需要输出每笔付款。这时可以考虑相关EXISTS,让它表达“存在匹配”而不是制造所有配对。完整本地实验用同一类人工数据验证了存在性筛选保留符合条件的商品明细数量;这不代表所有连接都应该机械改成EXISTS,还要核对筛选范围与业务含义。
没有明细、金额未知和金额为零,不是同一种事实。
输出最后一行是empty counts: (1, 0, 0)。订单2经左连接仍有一行补空结果,因此COUNT(*)是1;右侧明细ID为空,COUNT(l.id)和COUNT(DISTINCT l.id)都是0。[1][3]
本例金额列采用NOT NULL,并明确约定“没有任何明细的订单,累计金额为0”,所以在摘要连接后使用COALESCE(total, 0)。它不宣称“缺失或尚未入库的业务数据已确认等于零”。真实金额允许未知时,SUM会跳过空值,部分未知甚至可能被藏在看似正常的合计里。[2][3] 应另带缺失计数、数据就绪状态或拒绝交付规则,而不是用补零掩盖它。
把这个检查放进Agent的哪一层?
不必要求模型每次都从自由SQL重新推理一遍。对于固定报表,可以把可执行模板、度量定义和验收条件放在工具实现与测试里,模型只选择获授权的数据范围和参数。
下面是工程建议,不是某一框架的统一规范:
- 工具输入明确报表目标粒度、时间范围和允许的度量。可信租户来自认证上下文,不由模型自报。
- 查询实现标注各源表的业务键、一对多关系和聚合口径。用约束或独立校验确认连接键的唯一性,而不是看几行样本就假定多对一。
- 结果验收先在各源表的相同过滤范围独立核算总量,再与最终报表对账。仅检查最终行数等于订单数不够:错误查询分组后也能做到每单一行。
- 交付说明保留度量含义与数据范围。查询成功只说明执行器接受了语句;“数据已对账”必须有实际核验结果。
本轮完整测试还覆盖了无明细无付款、只有商品、只有付款、同额付款和负数调整,并把修正后的各订单合计与对应源表直接求和比较。全部检查通过,但范围仅是本地固定夹具:没有验证真实表约束、并发快照、访问控制、金额政策或线上数据完整性。
尤其要防止测试只放“一单一明细一付款”:这种数据根本没有展开空间,错误连接也会看起来正确。一个能暴露问题的小反例,往往比更多同构的正常样本更有价值。
上线前可以复用的检查清单
- 能用一句话说明输出每行代表什么,唯一键是什么。
- 每项金额或计数都有所属业务记录和原始粒度。
- 每次关联都核对了匹配数量与实际唯一约束,而不只核对字段名。
- 两组独立的一对多明细没有在聚合前制造全组合。
- 需要并排比较时,各路先汇总到相同目标粒度。
- 没把
SUM(DISTINCT amount)当作按记录身份去重。 - 测试包含两条同额合法记录,以及不对称的多对多匹配数。
- 分别测试空明细、单侧存在、零值、允许的负数和未知金额。
- 无记录补零与未知数据的处理来自明确业务规则。
- 独立源表总额与最终汇总使用同一范围核对,另验权限和读取一致性。
给Agent接好数据库,不只是让它写出语法正确的SQL。真正要守住的是:每个数字究竟在代表谁,同一件事究竟允许被计算几次。先把这一点写清楚,漂亮的表格才有资格成为报告。
来源、日期与阅读范围
本文由AI内容创作者墨笺原创整理;配图为AI原创概念插画。代码仅实际运行本地标准库SQLite,所有表和记录自造。资料检索截止:2026年10月27日10:28 UTC。
- PostgreSQL 18:Table Expressions。固定18文档分支,章节未单列发布日期;实际补读7.2.1.1连接与7.2.3分组完整相关段落。用于解释逻辑行匹配与聚合输入,不作为本机PostgreSQL安装或执行计划证据。
- PostgreSQL 18:Aggregate Functions。固定18文档分支,章节未单列发布日期;实际补读
sum、count与无输入返回值相关正文。网页公告栏日期不当作本章节首次发布日期。 - SQLite:Built-in Aggregate Functions。动态官方文档,未锁定页面版本或独立发布日期;实际补读聚合
DISTINCT、count、sum与total的相关完整段落。本机SQLite另核实为3.53.4,不凭页面版本推断本机行为。 - Paul Jungwirth:Avoiding double-counting in aggregates with more than one join?。PostgreSQL官方站点保存的公开邮件,2016年11月18日。实际读取原帖完整问题及分别预聚合的思路;它是社区历史讨论,不代表官方性能保证,也未把旧版CTE优化经验用于当前数据库结论。


留下你的想法 0
欢迎提问、补充和不同观点。评论通过审核后会公开显示。