Data Agent on DuckDB技术深潜 封面 决策表 附录
Internal Tech Deep Dive · GCR Web3 SA · 2026
Analytics · Agentic Data
把分析引擎放进 Agent 进程
Data Agent on DuckDB —— 一个可部署的四层架构实现:状态归 S3、治理归 SQL 重写层、调度归 Agent 平台、计算留在进程内
四条 S3 取数路径 › 一层重写式治理 › 零集群 Four S3 access paths · Rewrite-based governance · No cluster
Solutions Architect Amazon Web Services

开场不要念标题。第一句话说清这份材料要回答的那个具体问题:「当 agent 要分析 S3 上的数据,把引擎放在远端还是放在进程内?」

  • 这不是一个 demo,是一个跑在 AgentCore 上、有 CDK、有 40 个单元测试、有云上验收数字的参考实现。仓库四天内从 v1 baseline 演进到四路径 + 治理层,9 个 commit。
  • 四章的顺序就是我当初的推导顺序:先证明「为什么必须在进程内」(第 Ⅰ 章),再讲「数据怎么进来」(Ⅱ),再讲「安全怎么办」(Ⅲ),最后是「真正踩过的坑」(Ⅳ)。

可能被追问:「这不就是把 DuckDB 塞进 Lambda 吗?」→ 差别在治理层和四条访问路径,这两块是这个实现里真正难的部分,第 Ⅱ、Ⅲ 章各占一半时间。先看议程。

Agenda · 四章回答四个问题

为什么在进程内 · 数据怎么进来 · 安全怎么办 · 哪里会摔 30 分钟 · 26 页

为什么 in-process

Why in-process
  • Agent 一问 5–10 条 SQL
  • 四层架构各归其位
  • 3.9s vs 8.1s 实测

四条 S3 访问路径

Four access paths
  • raw · s3t · glue · dl
  • 同数据不同解析方式
  • 四路径结果完全一致

引擎前的治理层

Governance
  • AuthN / AuthZ 切分
  • sqlglot RLS/CLS 重写
  • fail closed 四条红线

工程细节与踩坑

Engineering
  • 读写门与 40 个测试
  • 只在云上暴露的并发 bug
  • CDK 最小权限

怎么用这份材料

  • 只关心「该不该用这个模式」→ 直接看第 章的 A/B 表和最后的决策表,其余可跳。
  • 关心多租户数据隔离→ 第 章是全篇技术密度最高的一章,尤其 schema-aware CLS 那页。
  • 准备自己搭一套 → 第 章的踩坑清单能省掉我花过的时间;附录有完整原始架构图
Data Agent on DuckDB · Agenda04 章 · 26 页 · 30 分钟02

议程只花 40 秒。要说的一句是:「四章是推导顺序,不是知识分类」—— 第 Ⅲ 章之所以存在,是因为第 Ⅰ 章的结论(引擎必须在进程内)直接引出了一个新问题:进程内的第三方引擎拿不到 Lake Formation 的行列过滤。

  • 先问一句「在场有多少人正在给 agent 接数据源」,按举手情况调整第 Ⅱ、Ⅲ 章的时间分配。
  • 如果听众里有做 platform 的,第 Ⅳ 章的 _conn_lock 那页值得多留 2 分钟,那是个通用陷阱,不只影响 DuckDB。

可能被追问:「能不能只讲治理」→ 可以,但先给 3 分钟看第 Ⅰ 章的 A/B 表,否则「为什么不用 Athena + Lake Formation」这个问题会一直悬着。现在进第 Ⅰ 章。

Part 1
Why in-process
为什么 in-process
Agent 写 SQL 的方式和人不一样:它靠试错收敛。一旦承认试错是主要成本,「引擎放哪」就不再是偏好问题,而是算术问题。
Agent 的试错循环 四层架构:状态·治理·调度·计算 A/B 实测:DuckDB vs Athena
Data Agent on DuckDB · 技术深潜
03

转场 20 秒。一句话:「这一章不讲 DuckDB 有多快,讲的是 agent 的失败次数怎么定价。」

  • 三页的顺序是:先看 agent 实际发出的 SQL 序列(现象),再看四层架构(解释),最后看实测(证据)。

先看现象 —— 一个自然语言问题在引擎侧到底变成了什么。

Ch.1 · 为什么 in-process · Trial-and-error loop

一个问题会展开成 5–10 条 SQL 试错本身就是主要成本,不是异常路径

真实序列 scripts/ab_compare.py 里逐字复现的五步

  • ① 探 schema —— SELECT txid, fee, output_value … LIMIT 5
  • ② 写错列名 —— sum(total_fee),引擎报错。这一步是必然会发生的
  • ③ 读错误信息、改正 —— count(*) + sum(fee)
  • ④ 分位数 —— approx_quantile(fee, 0.5/0.99)
  • ⑤ Top-10 —— ORDER BY fee DESC LIMIT 10
关键:第 ② 条是失败查询。远程查询服务的固定开销要为每一次重试付费 —— 它在 Athena 上花 0.51s(含 55ms 排队),在进程内只花 0.18s,而且不产生任何 token。

逐条对比 同一批 Parquet 文件、同一天分区

0s 1s 2s 3s 1.39 1.84 ① 探 schema ① 探 schema | DuckDB 1.39s | Athena 1.84s(排队 68ms,扫描 2.0MB) 0.18 0.51 ② 错列名 ② 写错列名(agent 必然会犯的错)| DuckDB 0.18s | Athena 0.51s + 55ms 排队,两侧都失败 0.65 1.35 ③ 改正聚合 ③ 改正后的聚合 | DuckDB 0.65s | Athena 1.35s(排队 90ms,扫描 1.1MB) 0.45 1.78 ④ 分位数 ④ 手续费分位数 | DuckDB 0.45s | Athena 1.78s。方言不同:approx_quantile vs approx_percentile 1.19 2.61 ⑤ Top-10 ⑤ Top-10 by fee | DuckDB 1.19s | Athena 2.61s,扫描 42.1MB(本序列扫描量最大的一条)
进程内 DuckDB 合计 3.9s Athena 合计 8.1s 失败查询也要计价
推论:判据是「一个问题的总时长」,不是「一条查询的时长」—— 而失败查询的倍数差(2.8×)比总倍数(2.1×)还大,所以 agent 错得越多,进程内的优势越明显
Ch.1 · 为什么 in-process — Agent 的试错循环04

这页要说服的是:「不要用『一条查询多快』来评估 agent 的数据后端,要用『一个问题多快』。」人写 SQL 一次写对,agent 是靠读错误信息收敛的。

  • 指着第 ② 组柱子说话。这是全页唯一需要讲的一组:失败查询在两边都失败,但代价不同。Athena 侧 0.51s 里有 55ms 是纯排队 —— 排队是为「没产出结果」的动作付的钱。
  • 数字口径:同一批 Parquet(s3://aws-public-blockchain 的 2026-08-25 分区)、同一批文件;DuckDB 侧是热进程的 S3 range read,Athena 侧是完整的 StartQueryExecution→poll 生命周期,也就是 MCP 工具每条查询实际要付的那一段。

可能被追问:「Athena 有结果缓存、也可以并发提交,为什么不算进去」→ 结果缓存对 agent 帮助有限,因为它每一条 SQL 都是新拼的;并发提交解决不了「必须读上一条的结果才能写下一条」这个依赖链。下一页把这个现象抽象成四层架构。

Ch.1 · 为什么 in-process · Four layers

四层各归其位,计算才敢放进进程 不是「把引擎塞进去」,是先把另外三层搬出去

Layer 1 · State
S3

状态外置

  • Parquet / Iceberg 在 S3
  • 内存库随会话消亡
  • 没有本地状态可泄漏
Layer 2 · Governance
重写层

治理外置

  • 引擎中立的 SQL 重写
  • 策略是配置不是代码
  • 换引擎不用重写策略
Layer 3 · Scheduling
1:1:1

调度外置

  • 1 会话 = 1 microVM = 1 引擎
  • 平台的会话调度即资源管理
  • 不需要第二个调度器
Layer 4 · Compute
进程内

计算内置

  • 毫秒级反馈闭环
  • 失败零 token 成本
  • 中间结果 = 进程内临时表
如果只做「计算内置」
引擎在进程内,但状态也在本地磁盘、策略硬编码在代码里、并发靠自己排队。 结果是把一个分布式系统的所有难题重新做了一遍,而且做得更差 —— 这是「嵌入式引擎」被诟病的真实原因。
四层都归位之后
引擎变成一个可丢弃的、无状态的、被治理的执行器。 它 OOM 了只影响自己那一个 microVM;它被换掉时策略不用动;它的并发上限由平台的会话配额定义,而不是由它自己定义。
口径:这四层不是本项目发明的分层,是把已被验证的做法写成可部署形态 —— AWS 在 2026-08-26 宣布收购 DuckLabs 时提到 「90%+ 的数据查询涉及 1TB 以内的数据」、DuckDB 「天然适合 AI agent 使用」;Amazon Quick 的引擎已用其 DuckDB 集成处理 2.5B+ 次查询。 本项目的定位是「在官方托管服务出现之前,任何账号里都能跑的最小实现」
Ch.1 · 为什么 in-process — 四层架构05

这页的论点是一个次序问题:「把引擎放进进程」之所以成立,前提是另外三层已经搬出去了。顺序颠倒就会得到一个很糟的架构。

  • 讲法:先念四张卡的标签(State / Governance / Scheduling / Compute),然后把时间花在下面的 Before/After 上。左边那个「只做计算内置」是很多人第一版真的会写出来的东西。
  • 具体到代码:状态外置 = duckdb.connect() 不带路径(纯内存);治理外置 = governance.py 独立模块 + GOVERNANCE_POLICIES 环境变量;调度外置 = 完全不写调度代码,靠 AgentCore 一会话一 microVM。

可能被追问:「1TB 以内是不是太乐观」→ 这个模式的适用边界在最后的决策表里有明确一行:工作集接近单节点内存就该拆查询或上移到 Athena/Redshift。本项目实测过 256MB 信封 + 落盘,全天排序 2.5s 完成,是「更慢但不死」。下一页给证据。

Ch.1 · 为什么 in-process · A/B benchmark

同一批文件上的 A/B:3.9s vs 8.1s 2.1×,而且失败查询的差距最大

口径:2026-08-28 实测 · 引擎在 us-west-2 / 数据在 us-east-2 · 由 scripts/ab_compare.py 生成 docs/ab-results.md · Athena 侧用分区投影建表,无 crawler
#查询DuckDB sAthena sAthena 排队 msAthena 扫描 MB成功 D/A
1探 schema1.391.84682.0✓ / ✓
2写错列名(agent 的错)0.180.51550.0✗ / ✗
3改正后的聚合0.651.35901.1✓ / ✓
4手续费分位数0.451.78561.1✓ / ✓
5Top-10 by fee1.192.615442.1✓ / ✓
合计(一个问题的完整试错序列)3.98.132346.22.1× 差距
2.1×
整个试错序列的总时长差
2.8×
失败那一条的时长差(0.51 / 0.18)
323ms
Athena 侧纯排队合计 · 零产出
三处最容易被误读: ① 这不是引擎性能对比 —— Athena 侧计的是 StartQueryExecution→轮询的完整生命周期,也就是一个 MCP 工具每条查询真正要付的那一段; ② 计费模型不同:Athena 按扫描字节计费(本序列 46MB),DuckDB 的边际成本是 S3 GET; ③ 分位数函数方言不同(approx_quantile vs approx_percentile),第 4 行不是同一个实现,不要单独引用这一行
Ch.1 · 为什么 in-process — A/B 实测06

这是第 Ⅰ 章的收口证据。要主动说清它不是什么:「这不是说 DuckDB 比 Athena 快 2 倍。」Athena 在多 TB 扫描上会反超,那是它的设计目标。这张表说的是「agent 的试错序列」这一个特定负载。

  • 高亮行(第 2 行)是本页唯一需要停留的地方:0.18 vs 0.51,而且 Athena 那 0.51 里有 55ms 是排队。一个问题里如果 agent 错两次,光排队就多付 100ms 以上,且完全没有产出。
  • 诚实修正:合计 2.1× 里有很大一部分来自固定开销而非计算。所以正确的推论是「查询次数越多、单查询越轻,这个模式优势越大」,而不是「DuckDB 更快」。

可能被追问:「那为什么不用 Athena 的 API 直连、省掉 MCP」→ 排队和启动开销是服务侧的,不是客户端的,绕不开。真正能绕开的做法就是把引擎搬进来 —— 这就是第 Ⅱ 章要讲的:搬进来之后,数据怎么进这个引擎。

Part 2
Four S3 access paths
四条 S3 访问路径
同一个引擎、同一份 agent 代码,只有「一个表名怎么解析成文件」不同。四条路径都是 READ_ONLY 挂载,各由自己的环境变量开关。
架构全景(动态图) 一次请求的五步 四条访问路径对比 Path D:DuckLake 两种 catalog 四路径一致性验收
Data Agent on DuckDB · 技术深潜
07

转场。一句话:「引擎不变,目录策略可以随治理需求演进。」这是这一章存在的全部理由 —— 四条路径不是四个方案让你挑一个,而是同一套代码在不同治理成熟度下的四种落法。

  • 本章顺序:先看全景图(结构),再看一次请求的五步(时序),再看四路径对比(选型),最后是一致性验收(证据)。

下一页是这份材料唯一的一张全景图,也是仓库里那张会动的架构图。

Ch.2 · 四条 S3 访问路径 · Architecture

一张图:身份在入口、治理在引擎前、四条路径对等 连线会动 —— 蚂蚁线是数据流向,圆点是一次请求

Data Agent on DuckDB Data Agent on DuckDB In-process analytics engine inside the agent — identity-aware governance, four S3 access paths · Bedrock + AgentCore Runtime AWS Cloud (us-west-2) Amazon Bedrock AgentCore Runtime session — 1 microVM Same data on S3 — four access paths End user natural language question Identity provider Cognito / Okta · JWT {tenant, role} AgentCore Identity inbound JWT authorizer · AuthN Strands Agent entrypoint: claims → role run_sql · governance role policy → RLS/CLS rewrite · AuthZ DuckDB (in-process) httpfs · 2GB envelope Amazon Bedrock Claude · writes SQL IAM exec role scoped S3 / catalog read CloudWatch Logs runtime logs · audit Amazon S3 — raw Parquet aws-public-blockchain (us-east-2) Amazon S3 Tables managed Iceberg · REST endpoint AWS Glue Data Catalog federated s3tablescatalog · same table as B DuckLake catalog catalog file on S3 · metadata DuckLake data Parquet on S3 RDS PostgreSQL (option) swap-in catalog · multi-writer login → JWT {tenant, role} invoke + JWT verified claims → session model loop · SQL / result SQL + role governed SQL Path A · path glob + range GET Path B · Iceberg REST (S3 Tables endpoint) Path C · Iceberg REST (Glue endpoint) Path D · DuckLake SQL catalog resolves to the same table metadata → Parquet 1 2 3 4 5 Flow 1 Authenticate (AuthN) user signs in at the IdP; AgentCore Identity validates the JWT at the service entry — only verified requests reach a runtime session 2 Claims → role the session entrypoint maps verified {tenant, role} claims to a role policy (identity lives in the IdP, policy in configuration) 3 Model loop the agent calls Amazon Bedrock (Claude): DESCRIBE, sample, fix errors, aggregate — engine feedback returns in milliseconds 4 Governed execution (AuthZ) run_sql rewrites the statement under the role policy — RLS predicate, column exclusion, raw-path denial — then in-process DuckDB executes 5 Four equal access paths, data stays on S3 A raw path glob · B S3 Tables Iceberg REST · C Glue Iceberg REST (resolves to B's table) · D DuckLake SQL catalog (file on S3; RDS PG swap-in for multi-writer) Verified 2026-08-31: same query, identical results on all four paths. Per-principal governance: admin 5.33M rows vs analyst 2.87M (RLS) with the fee column excluded. Engine A/B benchmark vs Athena: docs/ab-results.md.
Ch.2 · 四条 S3 访问路径 — 架构全景08

这张图有三个刻意的设计,投屏时按这个顺序指:①「AgentCore Identity 在 microVM 外面」—— 认证发生在 agent 代码运行之前,不是 agent 的职责;②「治理框在 DuckDB 上面」—— 引擎永远看不到未治理的 SQL;③「四条路径是同一区域里的平级框」—— 没有主次。

  • 这张图是代码生成的docs/architecture.gen.py 是唯一权威,SVG 是产物 —— 改结构要改 .py 再重新渲染,不要手改 SVG。这也是为什么图里的连线严格正交、标签不打架。
  • 注意图里 C → B 那条虚线箭头:Glue 联邦目录解析到的就是 S3 Tables 里那张物理表,不是副本。这一点在下下页的对比表里还会再强调一次。
  • 右下角 RDS PostgreSQL 是虚线可选项 —— 它被真正建过、验证过,也被拆掉了,原因在 Path D 那页讲。

可能被追问:「Bedrock 为什么画在 microVM 外面」→ 因为它是 agent 调出去的外部服务,不在会话进程里;把它画进去会让人误以为模型在本地。下一页把这张图的流程展开成五步。

Ch.2 · 四条 S3 访问路径 · Request flow

一次请求的五步 身份 → 角色 → 模型循环 → 受治理执行 → 取数

STEP 1
认证 AuthN

用户在 IdP 登录;AgentCore Identity 在服务入口校验 JWT,只有已验证请求能进会话

STEP 2
声明 → 角色

会话入口把已验证的 {tenant, role} 映射到角色策略:身份在 IdP,策略在配置

STEP 3
模型循环

agent 调 Bedrock(Claude):DESCRIBE、采样、读错误、改 SQL、聚合 —— 引擎反馈毫秒回来

STEP 4
受治理执行 AuthZ

run_sql 按角色策略重写语句:RLS 谓词、列排除、原始路径拒绝,然后进程内 DuckDB 执行

STEP 5
四条对等路径

A 路径 glob · B S3 Tables Iceberg REST · C Glue Iceberg REST · D DuckLake SQL 目录。数据始终留在 S3

每一步的边界属于谁

步骤责任方失败时的行为
1 认证AgentCore Identity请求根本到不了 agent 代码
2 声明→角色agent.py 入口回落到默认角色(可配置)
3 模型循环Bedrock + Strands引擎错误原样回给模型,自修复
4 重写governance.py解析不了就拒绝执行(fail closed)
5 取数DuckDB 扩展ATTACH 失败则该条腿不可用,其余不受影响

为什么必须是这个次序

  • 认证在 agent 之外 —— agent 代码不做验签,只消费已验证的声明。这条边界一旦模糊,整个信任模型就说不清了。
  • 重写在门禁之后、执行之前 —— 顺序颠倒(先执行再过滤)等于数据已经出库了。
  • 模型循环夹在中间 —— 模型可以随便写 SQL,因为它写的每一条都还要过第 4 步。这是敢让模型自由写 SQL 的唯一理由。
  • 取数在最后 —— 四条路径的差异被完全隔离在第 5 步,所以换路径不影响前四步。
Ch.2 · 四条 S3 访问路径 — 一次请求的五步09

这页是上一张图的文字版,价值在右边那张「为什么必须是这个次序」。要说服的一句是:「我们敢让模型自由生成 SQL,是因为它生成的每一条都还要过第 4 步。」

  • 把第 3 步和第 4 步的关系讲透:模型是不可信输入源,不是被信任的组件。它写 read_parquet('s3://…') 想绕过表级策略时,第 4 步会直接拒绝 —— 第 Ⅲ 章有实跑的拒绝信息。
  • 左表最后一行值得一提:某条腿 ATTACH 失败不影响其他腿,因为每条腿是独立的环境变量开关;而且 build_catalog_doc(active_legs()) 只把真正挂上的腿写进系统提示词 —— 没挂上的腿绝不能泄漏到提示词里,否则模型会去查一个不存在的目录。

可能被追问:「第 2 步回落到默认角色,是不是等于绕过治理」→ 不是。默认角色由 GOVERNANCE_DEFAULT_ROLE 指定,部署时可以把它设成最严格的那个角色。第 Ⅲ 章会讲这个两阶段映射。下一页先把四条路径摆在一起对比。

Ch.2 · 四条 S3 访问路径 · Comparison

四条路径怎么选 差别只在「一个表名怎么解析成文件」

维度A · 原始 ParquetB · S3 TablesC · Glue 目录D · DuckLake
库内别名视图 / read_parquets3t.*glue.*dl.*
解析方式S3 路径 glob + LISTIceberg REST(S3 Tables 原生端点)Iceberg REST(Glue 端点,联邦 s3tablescatalogSQL 目录库一次查询 → Parquet range GET
需要 S3 LIST全历史 glob 要列 ~6000 分区零 LIST,指针链导航
元数据归属无 —— 文件即真相S3 Tables 服务持有当前版本指针Glue / Lake Formation一个 catalog 数据库持有全部快照与文件清单
能与谁共享仅本引擎引擎直连、纯 IAMAthena / Redshift 全组织可见接得上 DuckLake 的引擎
额外基础设施表桶(托管)联邦目录,IAM 模式单文件目录=零;多写入需 PG
环境变量开关恒开S3_TABLES_ARNGLUE_CATALOGDUCKLAKE_CATALOG
实测 engine_ms(同一天切片聚合)1.3s1.6s1.8s3.5s
怎么选:历史数据与临时文件走 A(无需任何 setup);需要 ACID / 压实 / 快照就默认从 B 起步(本表主推列); 要让 Athena、Redshift 看到同一批表就加 C —— C 和 B 是同一张物理表,只是换了个端点解析,不是副本;元数据极多(大量小表 / 频繁快照)时 D 的单次 SQL 解析更划算。
两处口径: ① 最后一行的 engine_ms2026-08-31 在同一个云上会话里连续跑完四条得到的 —— 进程预热状态一致,所以四个数彼此可比;但不要拿它们跟别处的绝对值比。 ② 四条腿全开的成本只发生在会话建立时各一次 ATTACH,不查询就不产生数据面费用 —— 所以「全开」是本项目做一致性验收的手段,不是推荐的生产配置。
Ch.2 · 四条 S3 访问路径 — 四路径对比10

不要逐行念。指着 tfoot 说「结论在这」,然后只展开两行:「需要 S3 LIST」和「能与谁共享」。这两行是真正的分界线,其余都是它们的推论。

  • 「需要 S3 LIST」那行是 A 路径的硬伤:全历史 glob 要列约 6000 个分区。所以系统提示词里有一条硬规则 —— 永远带 date 过滤,单天优先用显式路径。这条纪律是写在 prompt 里的,不是靠模型自觉。
  • B 和 C 是同一张物理表,这点最容易被听成「数据同步了两份」。它们的区别只有一个:B 是引擎直连、纯 IAM,不需要按主体授权;C 走 Glue 联邦目录(IAM 模式建的,无需逐主体 LF 授权),代价是多一跳治理,收益是全组织可见。执行角色为此多了 glue:Get*(限定到联邦目录路径)和 lakeformation:GetDataAccess
  • engine_ms 那行的口径:2026-08-31 云上同一个会话里跑完四条,所以进程预热状态一致,可比。D 慢是因为它多一次 catalog 数据库查询。

可能被追问:「四条都开销不开销」→ 开销只在 ATTACH 时一次性发生,不查就不花钱;本项目默认全开是为了做一致性验收。下一页单独讲 D,因为它有一个真实的选型教训。

Ch.2 · 四条 S3 访问路径 · DuckLake

Path D 的两种 catalog 形态 都实现了、都云上验过,默认选了更简单的那个

形态一 · 单文件目录(当前默认)
DUCKLAKE_CATALOG=s3://…/blockchain.ducklake
一个 DuckDB 格式的目录文件放在加固过的 S3 桶上(SSE-S3 + 阻断公开访问 + 仅 SSL), 通过 httpfs 直接 READ_ONLY 挂载。并发读安全,零额外基础设施。
形态二 · RDS PostgreSQL 目录(多写入场景)
DUCKLAKE_CATALOG=postgres:dbname=…,凭据从 Secrets Manager 取(绝不走环境变量)。 适用于摄取管道在提交快照、agent 同时在读的多写入库。代价:runtime 必须切 VPC 模式才能到 RDS, 且 microVM 连接短命而多,建议加 RDS Proxy。
切 VPC 模式会连带拖进来什么实际发生的事
agentcore configure --vpc打印成功,但不会真的写进 yaml —— 必须手改 network_configuration
bedrock-runtime 私有 DNS接口端点的私有 DNS 会覆盖 NAT 路径;安全组不放行 runtime SG 的 443 → invoke 直接 500「Connect timeout」
退回 PUBLIC 之后删 runtime 安全组要等服务侧回收 ENI,不能立即删
结论RDS/VPC 这条腿建过、验证过、又拆掉了(2026-08-31);代码保留,默认不启用
1 个文件
形态一的全部「基础设施」
+VPC +RDS +Proxy
形态二的真实代价
建 → 验 → 拆
2026-08-31 同一天完成的三个动作
取舍要说清:把 catalog 从「S3 上一个文件」换成「一个 PostgreSQL 实例」, 换来的是多写入能力,付出的是一个需要 VPC、需要连接池、需要轮换凭据的常驻组件 —— 对一个「零常驻计算」的架构来说这是最重的一笔。所以默认值必须是单文件形态, 只有真的出现并发写入者时才切
Ch.2 · 四条 S3 访问路径 — DuckLake 两种 catalog11

这页讲的是一个被撤销的决定,比讲一个成功的决定有用。要说的一句是:「多写入能力不是免费的,它的价格是一个常驻组件加一整套 VPC 网络。」

  • 时间线可以直接讲:2026-08-31 那天先把 Path D 默认切到 RDS PostgreSQL 目录 + VPC 模式 runtime,验证通过,然后同一天又 revert 回单文件 + PUBLIC。仓库里能看到这两个 commit 一前一后。
  • 中间那张表的第二行是最值钱的一条:VPC 里只要装了 bedrock-runtime 接口端点,它的私有 DNS 会覆盖走 NAT 的公网路径;这时安全组没放行就会表现成「invoke 超时 500」,而错误信息指向 bedrock-runtime,很容易误判成模型或权限问题。

可能被追问:「那多写入到底怎么办」→ 两条路:真的需要就切 postgres 形态并加 RDS Proxy(代码已支持);或者让摄取侧写 Iceberg(Path B)而 agent 读 Path B —— S3 Tables 服务本身就在管当前版本指针,天生支持写读并发。下一页给四条路径的一致性证据。

Ch.2 · 四条 S3 访问路径 · Acceptance

四条路径,同一个问题,完全一致 2026-08-31 云上单会话验收

同一天切片行数
620,604
四条路径逐字相同
合计手续费
3.14201333BTC
四条路径逐位相同
各腿装载切片
5.3M 行
2026-08-20 ~ 08-28 · 由 DuckDB 自己写入
验收结论
4 / 4
一个会话内跑完四条,无需重启

引擎侧耗时 同一会话、同一预热状态,可比

A · 原始 Parquet
1.3s
B · S3 Tables
1.6s
C · Glue 目录
1.8s
D · DuckLake
3.5s

每次 run_sql 返回都带第二行 {"engine_ms", "rows", "governed"}, 系统提示词要求模型在总结里引用 engine_ms —— 所以这些数字是模型自己报出来的,不是我另外测的。

这次验收顺手抓到一个 bug

  • 症状:云上 count(*) 返回 0 行,本地怎么跑都正常
  • 原因:Strands 把同一条消息里的多个工具调用派到不同线程,共享的 DuckDB 连接只有一个活动结果集
  • 修法:_conn_lock 串行化 run_sql;回归测试钉住
  • 为什么不能用 per-thread cursor:那样临时表就跨调用不可见了

细节在第 Ⅳ 章有一整页 —— 这里只说明:四路径验收的价值不只是「结果对」, 而是它把一个只在云上出现的并发缺陷逼了出来。

验收结论

  • 「换目录策略不影响正确性」这句话只能用逐位相同来证明 —— 3.14201333 BTC 四条路径完全一致,说明四条腿读到的是同一份事实,不是四份近似。
  • 端到端验收的价值不在「跑通」,而在它逼出了一个本地永远绿的并发缺陷。本地过 ≠ 云上过,差异点通常是运行环境(线程模型、凭据、区域)。
Ch.2 · 四条 S3 访问路径 — 一致性验收12

这页的说服力在「逐位相同」。3.14201333 BTC 这个数四条路径完全一致,说明四条腿读到的是同一份事实,不是四份近似。这是「换目录策略不影响正确性」这句话的唯一证据形式。

  • 顺手强调一个设计:engine_ms 是模型自己在答案里引用的,因为工具返回值里带了这一行、提示词又要求它引用。把可观测性做进工具协议,比事后另外埋点更省事。
  • 右边那张卡要主动讲:这次验收不是「跑通了」,是「跑通并抓到一个本地测不出来的 bug」。这才是端到端验收的意义 —— 本地过了不代表云上过了,运行环境差异(线程模型、凭据、区域)是最常见的分叉点。

可能被追问:「620,604 行为什么不是 5.3M」→ 5.3M 是各条腿装载的整个切片(8 月 20–28 日九天),620,604 是验收那条查询针对的单天。两个数不是一回事,别混。下一章讲:这四条路径上的行列级权限怎么管。

Part 3
Governance before the engine
引擎前的治理层
对一个第三方进程内引擎,Lake Formation 的授权止于表级。所以细粒度治理必须发生在 SQL 到达引擎之前 —— 这正是生产 BI 引擎用来注入行级安全的那套重写模式。
AuthN / AuthZ 切分与 claims 契约 重写语义:真实 before / after SQL schema-aware CLS 为什么不用 Lake Formation 行列过滤
Data Agent on DuckDB · 技术深潜
13

转场。这一章是全篇技术密度最高的一章,开场就把结论说了:「把引擎搬进进程,代价是失去了引擎自带的行列级治理。这一章讲怎么把它补回来,而且补得比原来更可移植。」

  • 四页顺序:先切清 AuthN / AuthZ 的边界(谁负责什么),再看重写到底做了什么(真实 SQL),再看最难的一处(schema-aware CLS),最后回答「为什么不用 Lake Formation」。

先把边界切清楚,否则后面讲重写会一直有人问「那认证呢」。

Ch.3 · 引擎前的治理层 · AuthN / AuthZ

两层、一个窄契约 契约就是「已验证的声明」四个字

身份来源的信任顺序 _resolve_claims(),最可信的在前

  • ① JWT claims —— 生产路径。入站 JWT authorizer 在 agent 代码运行之前就验过签; 此处只解码取声明,不再重复验签(重复验签是安全剧场,不是安全)
  • X-Amzn-…-Runtime-User-Id —— SigV4 路径。调用方应用已经认证过它自己的终端用户, 按 <tenant>:<role> 约定传入
  • ③ payload 里的 identity 对象 —— 供内联传租户/角色的调用方使用。 SigV4 调用方是 IAM 认证过的基础设施,被信任来断言其用户身份 —— 与 ② 同一个信任模型
  • ④ 都没有GOVERNANCE_DEFAULT_ROLE。部署时应把它设成最严格的角色

两阶段映射,把身份变动挡在策略管理之外

STAGE 1
principal → role

IdP 验证过的声明 → tenant:role 键。未知角色回落到该租户最严格的策略,不是回落到 admin

STAGE 2
role → policy

行过滤谓词 + 列拒绝清单 + allow_raw_paths 开关。来自 GOVERNANCE_POLICIES JSON 环境变量

为什么要两阶段:IdP 里的人员进出、组织调整每天都在发生; 策略表里只该有角色,不该有人。策略是配置,不是代码 —— 改策略不需要改代码、不需要重新部署镜像。 规模再大就把这张表搬到 DynamoDB,resolve / rewrite 的契约不变

边界纪律:governance.py 里没有一行验签代码, agent.py 里没有一行策略判断。认证归 AgentCore Identity,授权归重写层 —— 这条切分要在 code review 里守住,一旦某一侧开始「顺手也做一点」另一侧的事,信任模型就再也说不清了。
Ch.3 · 引擎前的治理层 — AuthN / AuthZ 切分14

这页要说服的是:「不要在 agent 里做认证。」agent 代码只消费「已验证的声明」这一个窄契约,这样它既不需要知道 IdP 是谁,也不需要保管任何验签密钥。

  • 信任顺序里第 ① 条常被质疑:「不验签怎么行?」回答要具体 —— 入站 JWT authorizer 是 AgentCore 的能力,它在你的代码被调用之前就已经拒绝了无效 token。代码里再验一次拿不到新信息,反而要管密钥、管时钟、管 JWKS 缓存,净增攻击面。
  • 两阶段映射里最容易做错的是回落方向:未知角色必须回落到该租户「最严格」的策略,绝不能回落到 admin。代码里的实现是:tenant:role 精确命中优先,没命中就取该 tenant 的第一条策略,都没有才用默认角色。

可能被追问:「同一个进程里会不会串号」→ 不会,一个会话 = 一个 microVM = 一个主体,所以活动角色是模块级槽位,这是刻意的设计而不是偷懒;但它确实意味着不能在一个会话里服务多个用户。下一页看重写具体做了什么。

Ch.3 · 引擎前的治理层 · Rewrite

引擎实际看到的 SQL 长这样 下面是真跑出来的输出,不是示意

-- 主体 cex-a:analyst 问「2026-08-25 有多少笔、最大手续费多少」
-- ① 模型写出的 SQL(进入 governance 之前)
SELECT count(*) AS n, max(fee)
  FROM btc_transactions
 WHERE date='2026-08-25'

-- ② apply_governance() 的输出(引擎唯一看到的东西)
SELECT COUNT(*) AS n, MAX(fee) FROM (
  SELECT * EXCLUDE (fee)          -- CLS 列级
    FROM btc_transactions
   WHERE date >= '2026-08-24'       -- RLS 行级
) AS btc_transactions
 WHERE date = '2026-08-25'

-- ③ 换任意一条访问路径,策略照样命中(按裸表名匹配)
SELECT t.txid FROM (
    SELECT * EXCLUDE (fee)
      FROM s3t.blockchain.btc_transactions
     WHERE date >= '2026-08-24'
  ) AS t JOIN eth_blocks AS b
    ON t.block_number = b.number   -- 未受治理的表原样保留

四条语义 sqlglot,duckdb 方言

  • ① 包裹而非拼接 —— 每个受治理表引用被替换成 (SELECT * EXCLUDE(…) FROM ref WHERE …) AS alias别名保留,所以 JOIN 里的 t.txid 不会断
  • ② 按裸表名匹配 —— 故意不区分 catalog/schema,所以 raws3t.*glue.*dl.* 四条路径同一条策略,加新路径不用改策略
  • ③ 语句自己引入的名字跳过 —— CTE 名、CTAS 目标名不是受治理引用, 否则 WITH w AS (SELECT * FROM btc_transactions) … FROM w 会被套两层
  • ④ 显式投影被拒列 → 引擎报错,而不是返回 NULL 或静默丢列。 报错是正确行为:模型会如实告诉用户「没有 fee 列」
Fail closed 两条:受限主体的 read_parquet/read_csv/read_json 直接拒绝(否则路径访问就整体绕过了表级策略); 解析不了的 SQL 也拒绝 —— 不是「放过去让引擎自己判断」。
Ch.3 · 引擎前的治理层 — 重写语义15

这是全篇最该慢下来的一页。指着高亮那三行说:「这就是全部。行级是一个 WHERE,列级是一个 EXCLUDE,包在一个子查询里。」没有自研 SQL 引擎,没有 AST 魔法,就是生产 BI 引擎注入行级安全的那套老办法。

  • 第 ③ 段代码是最能打动人的一段:同一条策略,换成 s3t.blockchain.btc_transactions 照样命中,而同一句 SQL 里未受治理的 eth_blocks 原样不动。这说明策略是按逻辑表管理的,不是按物理路径管理的 —— 加第五条访问路径时策略表一行都不用改。
  • 右边第 ④ 条要讲透:受限主体显式写 SELECT fee 时,重写后的语句里 fee 已经被 EXCLUDE 掉,引擎会报 binder 错误。这比返回 NULL 好得多 —— NULL 会让模型以为「这天没有手续费数据」,从而给出错误结论;报错会让模型如实说「这个列不可见」。

可能被追问:「模型能不能用 SELECT * FROM (SELECT fee AS f …) 绕过」→ 不能,重写是在 AST 上做的,任何层级的表引用都会被包裹。真正的绕过入口是原始文件路径,所以那条被单独 fail closed 掉了。下一页讲这套重写里最难的一处。

Ch.3 · 引擎前的治理层 · Schema-aware CLS

为什么不能盲目 EXCLUDE 同一个逻辑表,四条路径暴露的列不一样

问题 两个事实撞在一起

  • 事实 A:原始视图是全历史 glob,而 read_parquet 的 glob 取第一个文件的 schema —— 2009 年的比特币文件没有 fee
  • 事实 B:在 DuckDB 里 EXCLUDE 一个关系并不暴露的列,是 binder 错误,不是无操作
  • 撞出来的结果:同一条策略 deny_columns:["fee"], 走 s3t.* 正常工作,走原始视图直接把查询打挂
注意这里的诱惑:最省事的写法是 try: EXCLUDE except: 不 EXCLUDE。那就是静默 fail open —— 列级策略在某些路径上无声失效,而日志里看不出任何区别。

解法 按引用探测 schema,取交集

1
注册探针

agent 侧把 DESCRIBE SELECT * FROM <ref> LIMIT 0 注册给 governance,按引用字符串缓存

2
取交集

拒绝清单 ∩ 该引用实际暴露的列。关系里根本没有的列本来就取不到,跳过它不会放宽访问

3
探测失败 → 保留完整清单

最坏情况是引擎拒绝这条查询。fail closed,永不 fail open

一个连带的并发细节:重写发生在 run_sql 的锁之外,所以探针必须自己去取 _conn_lock—— 否则它的 DESCRIBE 会和正在执行的查询抢同一个结果集。这行注释在代码里是留给下一个人的。

Ch.3 · 引擎前的治理层 — schema-aware CLS16

这页是整个实现里我最想让人带走的一页。要说服的一句:「降级可以,但降级必须留痕;『检查失败』和『检查通过』在行为上必须长得不一样。」

  • 先把矛盾讲清楚:策略写的是逻辑表 btc_transactions,但这个逻辑表通过四条路径暴露出不同的列集合。原始视图那条腿因为 glob 绑定了 2009 年的 schema,根本没有 fee 这一列 —— 而 EXCLUDE 一个不存在的列在 DuckDB 里是硬错误。
  • 然后讲那个「诱惑」:try/except 掉这个错误,查询就能跑了,测试也会绿 —— 但列级策略在那条路径上已经无声失效了。正确做法是探测 + 取交集,而探测失败时保留完整拒绝清单,宁可让引擎报错。跳过「关系里本来就没有的列」是安全的,因为取不到的列不构成泄漏。
  • 最后那个并发细节值得单独说:探针跑在 run_sql 的锁之外,所以它得自己加锁。这类「两个不变量在同一资源上打架」的地方,是这个项目里最容易再次被改坏的部分 —— 所以代码里写了原因,不只写了做法。

可能被追问:「为什么不干脆统一四条路径的 schema」→ 可以,但那就要求原始视图也只覆盖近期切片,而原始路径的价值恰恰是「能查 2009 年至今的全历史」。保留 schema 差异、让治理层去适配,比削掉能力更合理。下一页回答那个一直悬着的问题。

Ch.3 · 引擎前的治理层 · Why not Lake Formation

为什么不用 Lake Formation 的行列过滤 它没坏,它只是不覆盖这个位置

LF 数据过滤器的生效位置

  • LF 的行/列过滤是在 LF 集成引擎内部强制执行的 —— Athena、Redshift、EMR 都在这个名单里
  • 对一个第三方进程内引擎,LF 的授权止于表级:能不能读这张表,能;读到哪些行哪些列,LF 管不到
  • 所以本项目的 Path C 走 Glue 联邦目录时,用的是 IAM 模式,执行角色拿的是 glue:Get*(限定到联邦目录路径)+ lakeformation:GetDataAccess
  • 反过来说:重写层是引擎中立的。同一张策略表在四条访问路径上一致生效, 换掉 DuckDB 也不用重写策略 —— 这是「把治理外置」比「依赖引擎自带治理」更值的地方
结论:要给嵌入式引擎做细粒度治理, 只能在 SQL 到达引擎之前做。这不是绕开 LF,这是 LF 覆盖不到的那一段。

双主体云上验收 2026-08-31 · 同一个问题

维度ops:admincex-a:analyst
返回行数5,326,6462,865,507 RLS 窗口
max(fee)0.37090449列不存在 模型如实报告
原始 read_parquet允许被拒 附可解释的错误
governed 标记falsetrue
响应信封principal 原样回显,便于审计

策略来自 GOVERNANCE_POLICIESops:admin 不受限; cex-a:analyst = 行窗口 date >= '2026-08-24'、拒 fee、禁原始路径。 改策略不改代码。

这一章的收口

  • 措辞很重要:不是「LF 不好用」,是「LF 的细粒度过滤对第三方嵌入式引擎止于表级」 —— 我们补的是它覆盖不到的那一段。
  • 治理做对了,模型的诚实是免费的副产品:它看不到的列自然编不出来。反之若返回 NULL,模型会推理成「这天没有手续费」—— 那是治理层制造的幻觉。
Ch.3 · 引擎前的治理层 — 为什么不用 Lake Formation17

这页要避免一个常见的表达错误。不要说「LF 不好用」,要说:「LF 的行列过滤在它集成的引擎里工作得很好,而我们这个引擎不在那个名单里 —— 所以我们要在它管不到的那一段自己补。」这个措辞差别决定了客户会不会觉得你在贬低 AWS 自己的服务。

  • 右边这张验收表是全篇最硬的证据:同一个问题、同一套代码、只换主体,admin 看到 5,326,646 行,analyst 看到 2,865,507 行;admin 能拿到 max(fee)=0.37090449,analyst 那边这个列在引擎眼里就不存在,所以模型的回答是「没有 fee 列」而不是编一个数。
  • 「模型如实报告」这一点值得多说一句:治理层做对了,模型的诚实就是免费的副产品 —— 它看不到的东西自然编不出来。反过来,如果我们返回 NULL,模型会推理成「这天没有手续费」,那就是一个由治理层制造出来的幻觉。

可能被追问:「审计怎么办」→ 目前是 governed 标记进工具返回、principal 进响应信封、日志进 CloudWatch。README 明确写了这是参考实现,生产前要补细粒度审计留痕、限流、告警。下一章讲那些真的摔过的地方。

Part 4
Engineering & gotchas
工程细节与踩坑
这一章的每一条都是真的付过代价的。最贵的一条只在云上出现、本地怎么测都是绿的 —— 而它的两个正确解法互相冲突。
四条腿怎么挂上来(ATTACH) run_sql 的读写门与 40 个测试 _conn_lock:只在云上暴露的 bug CDK 最小权限与一条命令装数据 踩坑清单
Data Agent on DuckDB · 技术深潜
18

转场。一句话:「前三章讲的是设计对不对,这一章讲的是实现会在哪里摔。」这一章对准备自己搭一套的人价值最高。

  • 五页里第三页(_conn_lock)是最通用的一条 —— 它跟 DuckDB 关系不大,跟「agent 框架把工具调用派到线程池」关系很大,任何持有单连接资源的工具都会撞上。

先看四条腿是怎么挂上来的 —— 一共不到 30 行。

Ch.4 · 工程细节与踩坑 · _attach_legs()

四条腿一共不到 30 行 每条腿一个环境变量开关,全部 READ_ONLY

# Path B — S3 Tables 原生 Iceberg REST 端点
c.execute("INSTALL iceberg; LOAD iceberg;")
c.execute(f"ATTACH '{S3_TABLES_ARN}' AS s3t "
          "(TYPE iceberg, ENDPOINT_TYPE s3_tables, READ_ONLY)")

# Path C — 同一张物理表,换成 Glue 端点(联邦 s3tablescatalog)
c.execute(f"ATTACH '{GLUE_CATALOG}' AS glue "
          "(TYPE iceberg, ENDPOINT_TYPE glue, READ_ONLY)")

# Path D — DuckLake:SQL 目录 + S3 上的 Parquet
c.execute("INSTALL ducklake; LOAD ducklake;")
c.execute(f"ATTACH 'ducklake:{DUCKLAKE_CATALOG}' AS dl (READ_ONLY)")

# 两个 S3 secret:区域不同,靠 SCOPE 区分
#   默认 us-west-2  → 签目录腿(托管存储 / ducklake 桶)
#   SCOPE 到公共桶 → us-east-2,签公共数据集
c.execute("CREATE OR REPLACE SECRET blockchain (TYPE s3, "
  "PROVIDER credential_chain, REGION 'us-east-2', "
  "SCOPE 's3://aws-public-blockchain')")

三个不显眼但必须的细节

  • ① 两个 secret、两个区域 —— 公共数据集在 us-east-2,目录腿在 us-west-2。 靠 SCOPE 把公共桶单独绑到 us-east-2,不加 SCOPE 就会用错区域签名
  • SET home_directory='/tmp' —— 容器 / SSM 里可能没有可解析的 HOME, 而 DuckDB 装扩展需要一个
  • ③ 别名 ≤ 4 字符 —— s3t / glue / dl。 别名会出现在系统提示词和每一条 SQL 里,短别名省 token 也少打错
加第五条腿的完整步骤: _attach_legs() 加挂载 → active_legs() 加开关 → _LEG_DOCS / _SCENARIO_ROWS 加提示词条目。 治理策略一行都不用改(按裸表名匹配)。
Ch.4 · 工程细节与踩坑 — 四条腿怎么挂上来19

这页要传达的是「便宜」:四条访问路径的全部代价就是这不到 30 行,加上三个环境变量。指着高亮那行说「Path D 也就一句 ATTACH」。

  • 右边第 ① 条是真踩过的坑:DuckDB 的 S3 secret 是按 SCOPE 匹配的。默认 secret 设 us-west-2 让目录腿正常签名,公共数据集另给一个 SCOPE 到 s3://aws-public-blockchain 的 us-east-2 secret。少了这个 SCOPE,公共桶请求会用 us-west-2 签名而失败——注意失败信息不会告诉你是区域问题。
  • 顺便说一个反直觉的点:公共数据集也需要 IAM 显式放行。runtime 会给所有 S3 请求签名,签了名的请求走的是授权路径,没有 allow 就是 403,不会回落成匿名公共读。CDK 里那条 PublicDatasetRead 语句就是为这个存在的。

可能被追问:「READ_ONLY 是不是形式主义」→ 不是,单元测试里有一条真的对本地 ducklake 挂载后执行 INSERT 并断言抛错。下一页讲那道门。

Ch.4 · 工程细节与踩坑 · Gate & tests

唯一放行的「写」是会话临时表 40 个单元测试,全部离网

# 只有这两类语句能过门
_READ_PREFIXES = ("select", "describe", "summarize",
                  "with", "show", "explain")
_TEMP_DDL = re.compile(
    r"^create\s+(?:or\s+replace\s+)?"
    r"temp(?:orary)?\s+(?:table|view)\s", re.IGNORECASE)

def _gate(sql: str) -> str | None:
    normalized = " ".join(sql.strip().lower().split())
    if normalized.startswith(_READ_PREFIXES): return None
    if _TEMP_DDL.match(normalized): return None   # 会话内、随进程消亡
    return "ERROR: only SELECT / … or CREATE TEMP TABLE|VIEW …"

# 被门挡掉的(每一条都有测试):DROP / DELETE / INSERT /
# UPDATE / 非 TEMP 的 CREATE / ATTACH / COPY TO / SET

为什么临时表值得开这个口

  • 它是「会话状态」这个价值主张的载体:一次 CREATE TEMP TABLE 扫一遍 S3,后续所有追问都打在进程内表上,engine_ms 从秒级掉到毫秒级
  • 没有持久化风险 —— 建在内存库里,随进程消亡,没有需要清理的东西 (仓库化方案要 CTAS + 事后清理)
  • 门是基于归一化后的前缀 + 正则,不是 SQL 解析 —— 简单到能被穷举测试

测试覆盖 pytest · 无网络 · 全绿

40
用例全部通过
0
需要网络的用例
0
ruff 告警

门禁 / 截断 / 指标 / 临时表跨调用可见性 / 路径开关 / 并发不偷结果 / 治理重写四条路径 / 别名保留 / CTE 与 CTAS 局部名 / 原始路径拒绝 / 不可解析拒绝 / 拒绝列不可达。目录腿逻辑用猴补环境变量 + 本地 ducklake 文件测,不碰网络。

为什么门要「简单到能被穷举」: 一个会做完整 SQL 解析的门,本身就是新的攻击面和新的 bug 来源。 所以这里的门只做归一化 + 前缀匹配 + 一个正则,然后用 10 条被拒语句 (DROP / DELETE / INSERT / UPDATE / 非 TEMP 的 CREATE / ATTACH / COPY TO / SET …) 把边界钉死。治理层才做 AST 级判断 —— 分工明确,各自都能被单独测试。
Ch.4 · 工程细节与踩坑 — 读写门与测试20

这页要说的是一个判据设计问题:门必须简单到能被穷举测试。它不解析 SQL,只做归一化 + 前缀匹配 + 一个正则 —— 因为一个复杂的门本身就会成为漏洞来源。

  • 把「为什么临时表能放行」讲成价值而不是妥协:它是「会话状态」这个卖点的载体。实测过的场景是 agent 自己建了一个 22,348 行的工作集,然后连着回答 P50/P99 的追问 —— 后面这些追问一次 S3 都没再扫。
  • 40 个用例里最该提的是并发那一条test_parallel_run_sql_calls_do_not_steal_results,8 个线程各查一个不同的 count,断言每个都拿到自己的结果。这条测试是为下一页那个 bug 写的,它把一个云上才复现的缺陷钉在了本地。

可能被追问:「前缀匹配会不会被注释绕过,比如 /*x*/DROP」→ 归一化只做小写和空白折叠,不去注释,所以这类语句前缀匹配不上、正则也匹配不上,会被拒绝——默认拒绝是对的方向。下一页讲那个最贵的 bug。

Ch.4 · 工程细节与踩坑 · _conn_lock

本地全绿、云上返回 0 行 两个都对的解法互相冲突,只有一个能选

症状与成因
云上 count(*) 「返回」0 行,本地任何跑法都正常。 成因:Strands 把同一条模型回复里的多个工具调用派到不同线程, 而 DuckDB 的 Python 连接只持有一个活动结果集 —— 两个未同步的 execute+fetch 配对互相偷走对方的行
修法
一把 threading.Lock 串行化 run_sql, 加一条回归测试(8 线程并发查不同 count,逐个断言拿到自己的结果)。 代码里写清了「为什么不能用另一种解法」,因为那才是下一个人会踩的地方。
候选解法能修好并发吗为什么不能用
每线程一个 cursor()临时表会各有各的命名空间 —— 直接打破「会话状态」这个不变量:上一次调用建的 ws 下一次看不见
每次调用新建连接同上,而且丢掉对象缓存、重复装扩展
一把连接级锁唯一同时满足两个不变量的解法:结果集安全 + 临时表跨调用可见
禁止并行工具调用要改框架行为,且白丢掉模型一次发多条查询的能力
连带的一处治理的 schema 探针跑在 run_sql 的锁之外,所以它必须自己取这把锁
40 / 40
本地测试全绿(当时也全绿)
0 行
云上 count(*) 实际返回的行数
1 锁 + 1 测试
修复的全部内容
这条最值得带走:「本地过、云上挂」几乎总是运行环境差异 (线程模型、凭据、区域),而不是 SQL 写错。所以排查顺序应该是先从 CloudWatch 把模型真正发出的 SQL 和工具返回值捞出来,再谈逻辑 —— 而不是先怀疑查询。
Ch.4 · 工程细节与踩坑 — _conn_lock21

这页是整章最通用的一条,跟 DuckDB 关系不大:「只要你的工具持有一个有状态的单例资源,而 agent 框架会把工具调用派到线程池,你就会撞上这个。」

  • 讲法:先描述症状(云上 count(*) 返回 0 行,本地全绿),让听众自己猜两秒 —— 大部分人会猜 SQL 或权限。然后揭示真因:结果集被另一个线程偷了。
  • 中间那张表是本页的核心,因为它展示了一个真实的工程处境:四个候选解法都能修好并发,但只有一个不破坏另一个不变量。per-thread cursor 看起来更"正确"(无锁、可并行),但它会让临时表各有各的命名空间,直接毁掉第 Ⅰ 章讲的「会话状态」价值主张。两个不变量在同一个资源上打架时,必须把取舍写进代码注释,否则下一个人会"顺手优化"成 cursor。

可能被追问:「串行化会不会成为瓶颈」→ 在这个架构里不会:一个会话 = 一个 microVM = 一个用户,锁的竞争只发生在同一条模型回复里的几个工具调用之间,而它们本来就要按顺序读结果。并发能力靠开更多会话来的,不靠一个会话内部并行。下一页讲怎么把这套东西部署出来。

Ch.4 · 工程细节与踩坑 · Deploy

一个栈、一条命令装数据 装数据的工具就是 agent 查询用的那个引擎

# scripts/load_s3tables.py —— 没有 Spark、没有 Glue job、没有 Athena CTAS
c.execute(f"ATTACH '{arn}' AS s3t "
          "(TYPE iceberg, ENDPOINT_TYPE s3_tables)")
c.execute(f"CREATE TABLE IF NOT EXISTS "
          f"s3t.blockchain.btc_transactions AS {source_select(days)}")

# DuckLake 侧:能就从刚写好的 Iceberg 表拷(同区域,快)
c.execute(f"ATTACH 'ducklake:{catalog}' AS dl (DATA_PATH '{data_path}')")
c.execute("CREATE SCHEMA IF NOT EXISTS dl.blockchain")
c.execute(f"CREATE TABLE IF NOT EXISTS dl.blockchain.btc_transactions AS {src}")

# 幂等:IF NOT EXISTS + 行数已匹配就跳过全量重载
# 用你自己的凭据跑,不是 runtime 执行角色 —— 那个角色只读

执行角色的四组语句 一条路径一组,全部限定 ARN

Sid给了什么
BedrockInvoke推理配置 + 基础模型
PublicDatasetRead公共数据集也要显式 allow(签名请求走授权路径,403 不是公共读)
S3TablesRead7 个只读动作,限定到表桶及其 /table/*
GlueFederatedReadglue:Get* 限定到联邦目录路径
LakeFormationAccessGetDataAccess(该动作不接受资源限定)
栈的边界:栈只管 runtime 依赖的东西 (表桶 + 命名空间、DuckLake 桶、执行角色、日志组)。AgentCore runtime 本身由 agentcore launch——它还不是稳定的 CloudFormation 资源。 所以完整清理 = agentcore destroy + cdk destroyscripts/cleanup.sh 把两步按依赖顺序串好了。
一条容易被跳过的推论: 加载脚本能用同一个引擎写出 Iceberg 表,说明这个「只读的分析引擎」其实是个完整的读写引擎 —— 本项目只是刻意把 agent 侧的写能力全部关掉(run_sql 门禁 + 四条腿 READ_ONLY 挂载)。 能力是有的,是被显式收走的;这比「它做不到」是完全不同的安全论证。
Ch.4 · 工程细节与踩坑 — CDK 与装数据22

这页有一个很好卖的点:「装数据的工具就是查询用的那个引擎。」指着高亮那两行 —— DuckDB 通过 S3 Tables 的 REST 目录自己写出了那张 Iceberg 表,5.3M 行,全程没有 Spark、没有 Glue job、没有 Athena CTAS。

  • 这不只是省事,它也是一个论证:如果这个引擎连 Iceberg 表都能写,那"它只是个玩具查询器"的印象就站不住了。而且加载脚本本身就成了一份可运行的示例代码。
  • 右表第二行是最容易忘的一条 IAM 细节:公共数据集也必须显式 allow。runtime 会给所有 S3 请求签名,签名请求走授权路径 —— 没有 allow 拿到的是 403,而不是回落成匿名公共读。第一次部署卡在这里的人不少。
  • 栈边界要说清楚:runtime 不在栈里,因为它还不是稳定的 CFN 资源。这是个诚实的现状说明,不是设计缺陷 —— 也意味着"两步清理"这件事必须写进文档,否则会留下孤儿资源。

可能被追问:「装数据用什么凭据」→ 用你自己的,不是 runtime 执行角色。那个角色是只读的,一次性写入不该给它写权限。最后一页把剩下的坑一次列完。

Ch.4 · 工程细节与踩坑 · Gotchas

剩下六条,都付过代价 按「第一次会卡多久」排序

① 全历史 glob 会取错 schema

read_parquet('…/*/*.parquet')第一个文件的 schema, 而 2009 年的比特币文件没有 fee。演示与查询一律用显式单天路径或日期区间。 这条同时是 schema-aware CLS 存在的原因。

② S3 Tables 托管桶拒绝 ListObjects

返回 MethodNotAllowed这是设计如此,不是权限配错: 托管存储只允许按 key 做 GET,导航靠指针链走 —— 也正是它「零 S3 LIST」的来源。 别去修这个"错误"。

③ agentcore 不能切换已有 agent 的部署类型

agentcore configure 不会把已存在的 agent 从 direct_code_deploy 切成 container手改 .bedrock_agentcore.yamldeployment_type + ecr_repository), 让它和已部署的 runtime 对齐。

④ VPC 模式的两个陷阱

configure --vpc 打印成功但不写 yaml,得手改 network_configuration。若 invoke 报 500「Connect timeout on bedrock-runtime」: 接口端点的私有 DNS 覆盖了 NAT 路径,而它的安全组没放行 runtime SG 的 443。

⑤ 一个会话一次只处理一个 invoke

并行 invoke 同一会话会拿到 Strands 的 ConcurrencyException。 做并发测试必须每个并发用新的 runtimeSessionId —— 这也正好对应「一会话一 microVM」的隔离模型。

⑥ 容器里可能没有可解析的 HOME

DuckDB 装扩展需要一个 home 目录,而容器 / SSM 环境里未必有。 连接建立后第一件事就是 SET home_directory='/tmp'。 少了它,扩展安装会以一个和 HOME 毫无关系的信息失败。

共同点:六条里有四条(②③④⑥)的错误信息都指向错误的方向 —— MethodNotAllowed 像权限、Connect timeout 像 Bedrock、扩展装不上像网络。 所以排查这类系统的第一步永远是「先把真实的请求和返回捞出来」, 而不是相信错误信息的字面指向。
Ch.4 · 工程细节与踩坑 — 踩坑清单23

不要六条都念,那要四分钟。做法:说一句「这六条都在材料里,会后自己看」,然后只展开 ② 和 ④——这两条最反直觉,也最耗时间。

  • ② 值得展开是因为它挑战直觉:一个"存储桶"不允许列举对象,看起来像 bug,其实是它性能优势的来源。理解了这一点,就理解了为什么 Path B 能做到零 S3 LIST 而 Path A 做不到。
  • ④ 值得展开是因为它的错误信息指向完全错的地方:报的是 bedrock-runtime 连接超时,真因是你为了访问 RDS 而进了 VPC,VPC 里的 bedrock-runtime 接口端点的私有 DNS 抢走了本来走 NAT 的解析,而那个端点的安全组没放行你的 runtime。整条因果链上没有一环是错误信息提到的。
  • 底部那条收口是本页真正想传达的方法论,可以慢一点说:这类系统里错误信息经常指向错误的方向,所以排查顺序是"先取证再推理"。

可能被追问:「这些坑有没有可能被官方修掉」→ ③④ 属于工具链成熟度,会变;②⑥ 是设计使然,不会变;① 是数据集本身的 schema 演进,永远存在。下一页给决策表。

Decision · 什么时候用、什么时候别用

这个模式的适用边界 把「不该用」写在前面,材料才可信

你的处境建议
数据已经在受治理的数仓里,查询次数少但每条都重直连数仓 MCP,跳过 DuckDB —— 这个模式帮不上忙
数据在 S3,agent 需要大量迭代,并发不可预测用这个模式 —— 试错成本和爆炸半径都在进程内
需要按租户做行级 / 列级访问控制用这个模式 —— 自带重写层。Lake Formation 的行列过滤只在它集成的引擎内生效
两种情况都有(多数真实场景)混合:DuckDB 快路径 + 数仓 MCP 兜底,按查询特征分流
工作集接近单节点内存上限拆查询,或沿谱系上移(Athena / Redshift)—— 别硬撑
结论:判据是「查询次数 × 单查询重量」, 不是「数据量」。次数多、单条轻 → 进程内;次数少、单条重 → 远端。
做法:先只开 Path A 跑起来(零基础设施), 真需要 ACID / 快照再加 Path B,要全组织可见再加 C。四条腿是渐进的,不是一次性选型。
风险:这是参考实现。 投产前要补:监控告警、限流、高可用、细粒度审计留痕、凭据轮换加固。README 里写明了。

一句话判据

  • 看的是 「查询次数 × 单查询重量」,不是数据量。一张 500GB 的表被问 3 次 → 远端;一个 2GB 的切片被问 40 次 → 进程内完胜。
  • 表里压暗的两行是「别用」 —— 先说清什么时候不该用,后面的推荐才有人信。唯一「只有这个模式能做」的是高亮那行:嵌入式引擎的行列级治理。
Decision — 适用边界24

这页要先讲压暗的那两行(第一行和最后一行),也就是"别用"的两种情况。先说清什么时候不该用,后面的推荐才有人信。

  • 核心判据要念出来:不是看数据量,是看「查询次数 × 单查询重量」。一个 500GB 的表被问 3 次,远端更合适;一个 2GB 的切片被问 40 次,进程内完胜。第 Ⅰ 章那张 A/B 表就是这个判据的量化版本。
  • 高亮那行(行列级访问控制)是这份材料的差异点:它是唯一一条"只有这个模式能做"的。理由在第 Ⅲ 章 —— LF 的细粒度过滤对第三方嵌入式引擎止于表级。
  • 第三个 .oc 必须念。把"这是参考实现、投产前还要补五项"说在前面,比让客户上线后自己发现要好得多。

可能被追问:「混合方案的分流规则怎么写」→ 一个够用的起点:带 date 过滤且预估扫描 < 单节点内存的走 DuckDB,其余走数仓;分流判断本身可以让模型做,因为它已经知道 schema 和分区规则了。附录有完整原始架构图。

Appendix · 完整原始架构图(含图注)
Data Agent on DuckDB Data Agent on DuckDB In-process analytics engine inside the agent — identity-aware governance, four S3 access paths · Bedrock + AgentCore Runtime AWS Cloud (us-west-2) Amazon Bedrock AgentCore Runtime session — 1 microVM Same data on S3 — four access paths End user natural language question Identity provider Cognito / Okta · JWT {tenant, role} AgentCore Identity inbound JWT authorizer · AuthN Strands Agent entrypoint: claims → role run_sql · governance role policy → RLS/CLS rewrite · AuthZ DuckDB (in-process) httpfs · 2GB envelope Amazon Bedrock Claude · writes SQL IAM exec role scoped S3 / catalog read CloudWatch Logs runtime logs · audit Amazon S3 — raw Parquet aws-public-blockchain (us-east-2) Amazon S3 Tables managed Iceberg · REST endpoint AWS Glue Data Catalog federated s3tablescatalog · same table as B DuckLake catalog catalog file on S3 · metadata DuckLake data Parquet on S3 RDS PostgreSQL (option) swap-in catalog · multi-writer login → JWT {tenant, role} invoke + JWT verified claims → session model loop · SQL / result SQL + role governed SQL Path A · path glob + range GET Path B · Iceberg REST (S3 Tables endpoint) Path C · Iceberg REST (Glue endpoint) Path D · DuckLake SQL catalog resolves to the same table metadata → Parquet 1 2 3 4 5 Flow 1 Authenticate (AuthN) user signs in at the IdP; AgentCore Identity validates the JWT at the service entry — only verified requests reach a runtime session 2 Claims → role the session entrypoint maps verified {tenant, role} claims to a role policy (identity lives in the IdP, policy in configuration) 3 Model loop the agent calls Amazon Bedrock (Claude): DESCRIBE, sample, fix errors, aggregate — engine feedback returns in milliseconds 4 Governed execution (AuthZ) run_sql rewrites the statement under the role policy — RLS predicate, column exclusion, raw-path denial — then in-process DuckDB executes 5 Four equal access paths, data stays on S3 A raw path glob · B S3 Tables Iceberg REST · C Glue Iceberg REST (resolves to B's table) · D DuckLake SQL catalog (file on S3; RDS PG swap-in for multi-writer) Verified 2026-08-31: same query, identical results on all four paths. Per-principal governance: admin 5.33M rows vs analyst 2.87M (RLS) with the fee column excluded. Engine A/B benchmark vs Athena: docs/ab-results.md.
图的来源与口径: 本图由 docs/architecture.gen.py 渲染成 docs/architecture.svg.py 是唯一权威(改结构改 .py 后重渲,不手改 SVG)。 图注里的验证结论:2026-08-31,同一问题四条路径结果完全一致;admin 5.33M 行 vs analyst 2.87M 行(RLS),fee 列被排除。 引擎 A/B 基线见 docs/ab-results.md(2026-08-28 实测)。 设计权威文档为 docs/spec.md(v4,Owner: Xin Jiang, GCR Web3 SA)。 本 deck 的账号标识一律占位化;区域取 us-west-2(引擎)/ us-east-2(公共数据集)。 代码事实截至仓库 main 分支 da591bcruff 全清、pytest 40 passed
Appendix · 完整原始架构图25

这页不讲,是留给会后自己看的。它保留了仓库里那张架构图的完整原始形态,包括被正文页裁掉的五步流程图注和底部的验证结论行。

  • 投屏时这页会被整页缩小(演示模式的缩放分母是页面实际高度),所以不要在现场讲这一页;要讲架构就回第 8 页那个裁剪版。
  • 「口径」那一栏比图本身更重要:半年后有人问「3.14201333 这个数哪来的」,这一栏能回答。链接会失效,口径说明能让读者自己判断数字还适不适用。

如果有人想要这张图的源文件,直接给 docs/architecture.gen.py —— 它是可执行的规格说明,比 SVG 有用。

Thank you!
Data Agent on DuckDB · 把分析引擎放进 Agent 进程
原作者 Xin Jiang
GCR Web3 Solutions Architect Amazon Web Services
带走这四样:
· 判据:查询次数 × 单查询重量(P24)
· 治理只能在引擎之前做(P17)
· 降级必须留痕:schema-aware CLS(P16)
· 本地绿 ≠ 云上绿:_conn_lock(P21)
仓库入口:
· docs/spec.md —— 设计权威、不变量
· docs/ab-results.md —— A/B 原始数据
· docs/architecture.gen.py —— 架构图规格
· scripts/cleanup.sh —— 一键拆干净

结束页会停留很久(提问都在这一页上),所以中间那栏「带走这四样」是提问的锚点 —— 它把四章各自的一句话结论压成了四行。

  • 如果没人提问,主动抛一个:「你们现在给 agent 接数据源,治理是怎么做的?」这个问题几乎总能引出真实场景。
  • 右栏是给想自己动手的人的:先读 spec.md 的不变量那一节,它比 README 更能说明"哪些地方不能改"。

收尾一句:这个模式的价值不在 DuckDB 快,在于它让 agent 的试错变得便宜 —— 而 agent 的能力上限,某种程度上就是它能负担多少次试错。