
文章目录
1. 先说结论:历史数据不是不变量
管道炸掉其实不可怕——报错、堆栈、告警,你至少知道出事了。真正难查的是另一种:代码一行没改,上游悄悄把数据改了,报表从某天起开始偏,而且不报任何错。
同一份官方 CSV 的两个归档版本,实测结果如下:
| 对比项 | 版本 A(2024-01-01) | 版本 B(2026-01-01) | 结论 |
|---|---|---|---|
| 文件字节数 | 595,670 | 595,670 | 完全相同 |
| 数据行数 | 12,046 | 12,046 | 完全相同 |
| 结构(表头) | 8 列 | 8 列 | 完全相同 |
| 行内容差异 | — | — | 786 行不同(6.52%) |
786 行差异全部是同一件事:Macau Ferry Terminal 被写成了 Macao Ferry Terminal。关键在被改的是哪些行——按年份拆开,2019 年 98 行、2020 年 688 行。上游在 2026 年初做了一次拼写统一,回头重写了 5 到 7 年前的历史记录。
这推翻了一个几乎所有增量管道默认的假设:历史数据落库后就是不变的。 一旦上游会回溯性改写,"增量追加 + 按业务键去重"的管道就会在改名当天分裂成两个实体:旧的 Macau 记一份、新的 Macao 再记一份,两边各自都对,合起来是错的。
这篇建的哨兵:版本清单 → 结构指纹 → 二分定位 → Claude 分级判定。
2. 数据源:归档接口的三个反直觉行为
香港「資料一線通」有一套歷史檔案接口(api.data.gov.hk/v1/historical-archive/,零鉴权),两个端点够用:
list-file-versions?url={编码后的文件URL}&start=YYYYMMDD&end=YYYYMMDD—— 返回该文件的全部历史版本时间戳get-file?url={...}&time=YYYYMMDD-HHMM—— 取指定时间点的那个版本
实测规模(以逐日通关 CSV 为例):906 个归档版本、覆盖 2024-01-01 至 2026-07-21、总体积约 2.24 GB。上游改了什么、何时改的都能回溯。但三个反直觉行为不先摸清就会踩:
① end 不能超过昨天。 传今天直接 400:invalid end parameter (later than yesterday),"看今天的归档"不成立。
② 不存在的 time 不报错,返回 200 + 31 字节 JSON。 传一个不在版本清单里的时间戳,拿到的是 {"message": "NOT FOUND"},状态码 200。管道里若只判断 status == 200 就写盘,你会静默写入 31 字节垃圾,行数从 12047 变成 1,后续聚合全部归零。
③ 元数据字段"新",不代表文件版本"新"。 data-dictionary-dates 实测更新到 2026-09-09,而文件版本 timestamps 停在 2026-07-21。用错字段就会以为归档是最新的。

另一条边界:归档不是每日连续的。933 天里缺了 37 天(4.0%),最长一段是 2025-08-30 至 09-09,连续 11 天没有任何版本。这 11 天里的上游变更永远无法审计——不是检测不到,是根本没有对照物。
3. 核心代码:指纹、差异、二分
import csv, io, json, hashlib, subprocess, urllib.parse
ARCHIVE = "https://api.data.gov.hk/v1/historical-archive"
def archive(endpoint: str, url: str, **params) -> str:
"""归档请求统一入口。用 curl 而非 urllib:该网关对 urllib 的请求特征会限流。"""
qs = "&".join(f"{k}={v}" for k, v in params.items())
q = urllib.parse.quote(url, safe="")
cmd = ["curl", "-sL", "-m", "90", f"{ARCHIVE}/{endpoint}?url={q}&{qs}"]
return subprocess.run(cmd, capture_output=True, text=True).stdout
def list_versions(url: str, start: str, end: str) -> list:
"""end 不能超过昨天,否则 400 invalid end parameter。"""
raw = archive("list-file-versions", url, start=start, end=end)
return json.loads(raw).get("timestamps") or []
def get_version(url: str, ts: str):
"""不存在的 time 不报 HTTP 错误,而是 200 + 31 字节 NOT FOUND JSON。"""
raw = archive("get-file", url, time=ts)
if len(raw) < 200 and '"NOT FOUND"' in raw:
return None # 不抛异常、不写盘,直接判定为"无此版本"
return raw
def fingerprint(text: str, enum_col: int = 1) -> dict:
"""结构指纹:表头 / 行数 / 枚举集 / 内容哈希。列级枚举比整文件哈希更能说明'变了什么'。"""
rows = list(csv.reader(io.StringIO(text.lstrip("\ufeff"))))
if not rows:
return {"header": (), "rows": 0, "enums": [], "md5": ""}
enums = sorted({r[enum_col] for r in rows[1:] if len(r) > enum_col and r[enum_col]})
return {"header": tuple(rows[0]), "rows": len(rows) - 1,
"enums": enums, "md5": hashlib.md5(text.encode()).hexdigest()}
def diff_rows(old_text: str, new_text: str) -> list:
"""逐行差异。两版行数相同时也能抓出'值被改写'——正是行数检测漏掉的场景。"""
a, b = old_text.splitlines(), new_text.splitlines()
return [{"line": i, "old": x, "new": y}
for i, (x, y) in enumerate(zip(a, b)) if x != y]
def bsearch_first(url: str, ts: list, pred) -> int:
"""二分定位'首次满足条件'的版本。906 版全量下载要 2.24GB,二分只要 10 次左右。"""
lo, hi = 0, len(ts) - 1
if not pred(get_version(url, ts[hi])):
return -1
while lo < hi:
mid = (lo + hi) // 2
if pred(get_version(url, ts[mid])):
hi = mid
else:
lo = mid + 1
return lo
四点值得说:get_version 的长度判断不靠状态码,靠"响应体小得不像数据"拒绝垃圾;fingerprint 用列级枚举集,因为哈希只能说"变了",枚举集能说"哪一列的取值集合变了";diff_rows 用 zip 对齐,行数相同也能逐行比对;二分是规模化的唯一原因——906 版全量下载不可行,12 次就定位到具体版本。三段式(清单 → 指纹 → 二分)建议收藏。
4. 案例 A:行数一模一样,786 行历史值被改写
两个版本取自归档清单首尾(20240101-1326 与 20260101-1255):
md5_changed = True
rows_same = True # 12,046 == 12,046
changed_rows = 786 # 6.52%
enums_same = False
old_enum_sample = ['Macau Ferry Terminal']
new_enum_sample = ['Macao Ferry Terminal']

- 行数比对在案例 A 上直接失效——12,046 行进、12,046 行出,信号恒为 0;786 行被改写,它一个字都不会说。
- **文件哈希(MD5)**两个案例都命中,但表达能力止步于"变了":说不出变的是哪一列,且对空白/BOM 一类无意义改动同样敏感。
- 结构指纹(表头 + 行数 + 列级枚举集)两个案例都命中,并直接指出变的是
Control Point这一列的取值集合——唯一既检测到变化、又描述了变化的信号。
案例 C 三个信号全是盲区:元数据字段说"已更新",文件版本其实没动。这不是检测能力问题,是信号选错了对象。
5. 案例 B:二分 12 次,定位到口岸名从 16 变 17 的那一天
第二个案例换更大的文件:906 版、2.24 GB,全量下载不现实,得靠二分。二分需要单调判定条件——这里用"文件里是否已出现 Macao Ferry Terminal",因为文件是累积的,一旦为真就永远为真。
结果:
| 项 | 值 |
|---|---|
| 二分次数 | 12 |
| 首个含新口岸名的版本 | 20260102-1302 |
| 前一个版本 | 20260101-1257(不含) |
| 数据行数 | 54,548 → 54,576(+28) |
| 口岸名数量 | 16 → 17 |
| 新版本中旧名是否仍在 | 是 |
最后一行才是关键。上游做的不是"重命名",是"新增"——新的 Macao Ferry Terminal 加进来,旧的 Macau Ferry Terminal 一行没删,两个名字在同一个文件里并存。行数 +28 也印证这点:14 个口岸 × 入境/出境 2 行,正好一天的新记录。
对下游的杀伤在于:管道若按口岸名做 GROUP BY,从 2026-01-02 起这个口岸会分裂成两个 KEY——历史数据是 Macau,新数据是 Macao,两边各自算得都对,加起来就是重复计数。它不报错、不冲突、类型也没变,告警面板上看不到。
第 4 节那张矩阵说的就是这个:枚举集的差异(16 → 17)比总行数的差异(+28)更有解释力。
6. Claude 层:两级设计,只在指纹变化处调用模型
那 Claude 放在哪?放在判定这一层。"变了一个枚举值"是事实,"要不要阻断发布"是判断——前者用代码更便宜更稳,后者交给模型更合适。
关键设计是两级:确定性层先把 906 个版本过滤成"指纹变化的那几处",模型只看这几处。把每版都喂给模型找不同既贵又慢,99% 的输入还是噪音。
DRIFT_TOOL = {
"name": "report_drift",
"description": "Report one detected upstream data drift with severity and downstream impact.",
"input_schema": {
"type": "object",
"properties": {
"drift_type": {"type": "string", "enum": [
"value_rename", "enum_add", "enum_remove",
"schema_change", "volume_anomaly", "noise"]},
"severity": {"type": "string", "enum": ["info", "warn", "block"]},
"affected_columns": {"type": "array", "items": {"type": "string"}},
"changed_rows": {"type": "integer"},
"downstream_risk": {"type": "string"},
"summary_zh": {"type": "string"},
"suggested_action": {"type": "string", "enum": [
"ignore", "log", "alert_human", "halt_pipeline"]},
},
"required": ["drift_type", "severity", "affected_columns",
"summary_zh", "suggested_action"],
},
}
def build_claude_request(drift: dict) -> dict:
"""只在指纹变化处构造请求——906 个版本全量投喂既贵又慢,99% 是噪音。"""
ctx = {
"old_fingerprint": {"header": list(drift["old"]["header"]),
"rows": drift["old"]["rows"],
"enums": drift["old"]["enums"]},
"new_fingerprint": {"header": list(drift["new"]["header"]),
"rows": drift["new"]["rows"],
"enums": drift["new"]["enums"]},
"changed_row_count": drift["changed_rows"],
"sample_old": drift["samples"][0]["old"],
"sample_new": drift["samples"][0]["new"],
}
return {
"model": "claude-sonnet-4-5",
"max_tokens": 800,
"tools": [DRIFT_TOOL],
"tool_choice": {"type": "tool", "name": "report_drift"},
"messages": [{"role": "user", "content":
"上游开放数据文件在两个归档版本之间发生变化。"
"请调用 report_drift 给出结构化判定。\n"
+ json.dumps(ctx, ensure_ascii=False, indent=2)}],
}
def validate_request(req: dict) -> list:
"""Local structural check - no live call needed just to find a typo."""
errs = []
if not req.get("tools") or req["tools"][0]["name"] != "report_drift":
errs.append("tool not mounted")
if req.get("tool_choice", {}).get("name") != "report_drift":
errs.append("tool_choice not forced")
props = req["tools"][0]["input_schema"]["properties"]
missing = [k for k in req["tools"][0]["input_schema"]["required"] if k not in props]
if missing:
errs.append(f"required keys missing from properties: {missing}")
return errs
两个设计点。① tool_choice 强制指定工具:不加这条,模型可能改用自然语言回答,下游就得再写一层文本解析;强制之后返回值一定符合 input_schema,直接入库。② schema 里的 enum 既是给模型的对齐器,也是给你的护栏:severity 只有 info / warn / block 三档,suggested_action 只有四档——"模型说要阻断管道"于是能被程序可靠判断,而不是一段要人读的散文。
投喂前先算账。每版指纹摘要约 127 tokens(本地 chars/4 估算):906 版全量投喂约 115,062 tokens;只在指纹变化处投喂(变化前后各 1 版 + 开销)约 514 tokens。

降幅 99.55%。 这不是模型能力问题,是架构问题:该用代码的地方用代码,该用模型的地方才用模型。
脚本里的模型部分零调用——validate_request() 在本地校验请求体(工具是否挂载、tool_choice 是否强制、required 字段是否都在 properties 里定义过),跑出来 request_valid: True。"代码写没写对"不必等真实调用才发现,也就不用为调试付账单。
7. 踩坑与避坑
| 坑 | 症状 | 正确姿势 |
|---|---|---|
| 把行数当变更信号 | 12,046 行进、12,046 行出,以为"没变",实际 786 行被改写 | 行数只能抓增删;值改写要靠逐行 diff 或列级枚举集 |
| 用状态码判断响应有效性 | 无效 time 返回 200 + 31 字节 {"message":"NOT FOUND"},被当数据写盘 | 加长度哨兵:len(raw) < 200 且含 NOT FOUND 直接判空 |
用 data-dictionary-dates 判断归档新旧 | 该字段更新到 2026-09-09,文件版本却停在 2026-07-21 | 只看 timestamps,别跨字段推断 |
end 传今天 | 400 invalid end parameter (later than yesterday) | end 最大取昨天 |
| 假设归档每日连续 | 933 天缺 37 天(4.0%),最长连续缺 11 天 | 审计前先跑覆盖检查;缺口期的变更无法审计,如实记录 |
| 把"改名"当成"替换" | 新名进来、旧名一行没删,两个 KEY 并存 → 重复计数 | 检测到枚举新增先查旧值是否仍在;同义改名要做映射 |
| 全量版本投喂模型 | 906 版约 115,062 tokens,99% 是噪音 | 两级:确定性指纹过滤 + 只在变化处调用 |
第一行和最后一行最值得记:一个教你不用便宜信号,一个教你省贵信号,整张表建议收藏。
参考链接
- 香港「資料一線通」開放數據平台 — https://data.gov.hk/
- Claude 官方文档:Tool use — https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview
原创声明:本文为原创技术实践。归档数据全部来自 data.gov.hk 歷史檔案接口的真实历史版本(比对快照 2026-09-10),确定性检测逻辑已本地跑通;模型部分零调用、零计费,token 数为本地 chars/4 估算(近似值,非官方 tokenizer),全部逻辑可复现。


278

被折叠的 条评论
为什么被折叠?



