Claude + Python 数据管道:一次字段改名,786 行历史数据被静默重写

在这里插入图片描述

1. 先说结论:历史数据不是不变量

管道炸掉其实不可怕——报错、堆栈、告警,你至少知道出事了。真正难查的是另一种:代码一行没改,上游悄悄把数据改了,报表从某天起开始偏,而且不报任何错。

同一份官方 CSV 的两个归档版本,实测结果如下:

对比项版本 A(2024-01-01)版本 B(2026-01-01)结论
文件字节数595,670595,670完全相同
数据行数12,04612,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_rowszip 对齐,行数相同也能逐行比对;二分是规模化的唯一原因——906 版全量下载不可行,12 次就定位到具体版本。三段式(清单 → 指纹 → 二分)建议收藏

4. 案例 A:行数一模一样,786 行历史值被改写

两个版本取自归档清单首尾(20240101-132620260101-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% 是噪音两级:确定性指纹过滤 + 只在变化处调用

第一行和最后一行最值得记:一个教你不用便宜信号,一个教你省贵信号,整张表建议收藏

参考链接

  1. 香港「資料一線通」開放數據平台 — https://data.gov.hk/
  2. 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),全部逻辑可复现。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值