AB
AiBoss站
教程

让 AI Agent 不再编造邮政编码:校验、路由与工具边界实战

教程

让 AI Agent 不再编造邮政编码:校验、路由与工具边界实战

语言模型会把训练数据里的旧格式当成事实,一本正经地写出早已失效的校验代码。本文以尼日利亚邮政编码从六位数字改为 11 位字符为例,讲清两件事:如何用项目规则纠正写代码的 Agent,以及如何用确定性代码为运行时 Agent 划出信任边界——解析、校验、调用官方接口、按前缀路由,并给 Agent 一套带硬性限制的工具。

语言模型有一个很难自查的毛病:它会把训练数据里占主导地位的格式当成当前事实,用非常自信的语气写出来。当某个编码规则在模型训练完成之后发生变化,这个毛病就会直接变成线上事故——Agent 写出的正则会把所有真实数据判为非法,或者 Agent 从用户消息里读出的编码根本没有被官方分配过。

本文以尼日利亚邮政编码为例。2026 年 10 月 1 日起,该国的邮政编码从六位数字改为 11 个字符的楼宇级编码,形如 EK-01-A03-FK-01。这个变化影响两类 Agent:一类是帮你写校验代码的编码 Agent,另一类是在运行时读取用户消息、填写配送表单的运行时 Agent。本文分别给出约束办法:对前者,先测量它今天会说什么,再用一条项目规则纠正它;对后者,建立一条信任边界——模型只负责提供原始字符串,是否格式正确、是否被官方分配、下一步流向哪里,全部交给确定性代码判断。文中示例使用 Python 3.10 及以上版本。

准备工作

先把环境准备好,再谈边界设计。需要的东西不多,而且大部分步骤可以完全离线完成。

  • Python 3.10 或更高版本。示例代码用到了结构化模式匹配(match / case),这是 3.10 才有的语法。
  • 一个 MCP 客户端,例如 Claude Code 或 Cursor,仅在需要把工具直接交给 Agent 时用到。
  • uv,它提供 uvx 命令,用于拉起 MCP 服务器。
  • 官方接口密钥,仅在需要做真实查询时用到。密钥从官方开发者面板获取。所有离线校验、解析、路由逻辑都不需要密钥。

安装带客户端支持的包:

pip install "ng-postcode[client]"

把密钥放进环境变量,不要写进代码、不要写进配置文件、更不要作为工具参数传给模型:

export NG_POSTCODE_API_KEY="你的密钥"

需要提醒的是,包的版本、接口的免费额度、付费规则都可能变化,动手前请以官网当前信息为准。

操作步骤

第一步:先测量编码 Agent 今天会写出什么

不要假设模型知道新格式,先测。测试要公平:在一个空目录里运行,让 Agent 读不到任何项目文件,同时允许它联网搜索,并在提示词里明确给出当天日期。

claude -p "I'm building a checkout in Python.
I need to validate Nigerian postcodes. What should I use?
Give the install command and a short example, in under 120 words.
Then end with exactly three lines:
LIBRARY: <the package you recommended, or none if you wrote your own regex>
FORMAT_ASSUMED: <the format you assumed>
SEARCHED: <yes or no>.
Today is 5 October 2026." --allowedTools "WebSearch,WebFetch"

在 Python、JavaScript、Rust、Java 四种语言上分别跑一遍,会看到一个高度一致的结果:四种语言都回答 LIBRARY: none,都假设格式是六位数字(部分还假设首位是 1 到 9),并且 SEARCHED: no——即使给了搜索工具、给了当天日期,模型仍然凭记忆写正则,没有去搜。

这不是某一次偶然。机制是通用的:模型复现的是训练数据里占绝对多数的格式,而新格式在训练数据里几乎不存在。它写出的代码会把当前所有真实编码判为非法:

import re
print(re.fullmatch(r"[1-9]\d{5}", "EK-01-A03-FK-01"))  # None

把这次测量当成一次可复现的读数,而不是结论。换你自己的工具、自己的语言、自己的日期再测一遍,结论可能不同,但机制不会变。

第二步:在源头纠正编码 Agent

Agent 写错是因为缺一条事实,那就把这条事实直接喂给它。把下面这段规则粘进项目的 AGENTS.md、CLAUDE.md 或编辑器规则文件:

Nigerian postcodes: since 1 October 2026 a building's postcode is NIPOST's 11-character digital postcode (NDAPS), such as EK-01-A03-FK-01, and a six-digit pattern does not match it. Validate and format it with ng-postcode (pip, cargo), ng-postcode-js (npm), or io.github.adeniyikayodee:ng-postcode (Maven). On npm, ng-postcode is a different project.

注意最后一句:npm 上同名的 ng-postcode 是另一个项目,包名容易踩坑,规则里必须点明。

加完规则后再跑一次同样的提示词,这次从只放了这条规则的目录里运行。结果会变成 LIBRARY: ng-postcode、FORMAT_ASSUMED: 11-character NDAPS digital postcode、SEARCHED: yes。规则确实能改善编码 Agent 的输出。

但规则本身有三个弱点:可能缺失、可能被忽略、可能过期。而且它对运行时处理编码的模型完全无效——运行时模型不会去读你的 CLAUDE.md。所以接下来要建的是不依赖模型自觉的检查。

第三步:划出信任边界

运行时模型是一个具有已知特性的组件:同样的输入可以返回不同的输出,其中一部分错误看起来完全正确。这类组件你早就设计过对策——网络会丢包,所以加校验和;用户会打错字段,所以加校验。模型属于同一类。

原则是每个组件只干一件事:

  • 模型负责载荷:从「my code is ek 01 a03 fk 01」这样的消息里把字符串抠出来。
  • 解析器负责格式:判断这个字符串是不是一个合法编码。
  • 官方接口负责归属:判断这个编码是否被分配给了某个楼宇。
  • 你自己的代码负责路由:读前缀,决定进哪个站点或队列。

这四个判断中,没有任何一个把模型的置信度当作证据。

编码本身的结构很适合这种设计,它由五段组成,每一段都在收窄前一段的范围:

段示例规则含义
1EK两个字母地方政府区域(州)
201两位数字,01 到 99区
3A03三个字母或数字片区
4FK两个字母楼宇单元
501两位数字,01 到 99楼宇编号

大多数位置上的一个错字符就会破坏一条规则,而这条规则可以离线检查,不需要网络调用,也不需要模型参与。同时,编码的每一个前缀都指向一个更小的区域,从州一路收窄到单栋楼。

第四步:建一道闸门

闸门是一个函数,模型产出的每一个编码在进入系统其他部分之前都必须先过它。parse 返回 Postcode 或错误值,类型检查器会强制你处理两种情况。

from ng_postcode import Corrected, Postcode, parse, parse_lenient

def gate(text: str) -> Postcode | str:
    match parse(text):
        case Postcode() as code:
            return code
        case error:
            match parse_lenient(text):
                case Corrected(postcode=fixed):
                    return f"refused: {error}. Ask the user: did you mean {fixed}?"
                case _:
                    return f"refused: {error}"

for text in ["ek 01 a03 fk 01", "100001", "EK-00-A03-FK-01", "EK-O1-A03-FK-01"]:
    print(gate(text))

四种输入对应四种结果:

  • ek 01 a03 fk 01 被接受并规范化成 EK-01-A03-FK-01。
  • 100001 是旧的六位编码,闸门明确指出「expected 11 letters and digits, found 6」。
  • EK-00-A03-FK-01 的区号是 00,格式不允许,报「invalid lga segment」。
  • EK-O1-A03-FK-01 在只能是数字的位置放了字母 O,parse_lenient 能猜出大概想写什么,于是返回「refused: invalid lga segment. Ask the user: did you mean EK-01-A03-FK-01?」。

关键在最后一条:纠正只是猜测,所以它不能直接进数据库,也不能触发付费接口调用,必须先以问句形式交给用户确认。闸门拒绝输入,把建议作为问题返回,这个顺序不能颠倒。

第五步:向官方接口确认编码是否被分配

闸门只能证明字符串格式正确。这个编码是否被官方分配给了某栋楼,是另一个问题,用一次 level 1 查询就能回答,且不产生费用。

import os
from ng_postcode import Postcode, parse
from ng_postcode.api import ApiError, lookup
from ng_postcode.client import Client, TransportError

def assigned(code: Postcode, client: Client) -> bool | str:
    match client.send(lookup(code, level=1)):
        case ApiError() | TransportError() as failure:
            return f"unconfirmed: {failure}"
        case found:
            return found.valid

code = parse("EK-01-A03-FK-01")
assert isinstance(code, Postcode)
with Client(api_key=os.environ["NG_POSTCODE_API_KEY"]) as client:
    print(assigned(code, client))

输出是 False。原因很直白:EK-01-A03-FK-01 是官方文档里的示例编码,并不属于任何真实楼宇。

注意错误处理的设计:密钥被拒或连接失败时返回的是以 unconfirmed 开头的字符串,这样就把「查询失败」和「编码未被分配」区分开了。两者绝不能混为一谈——前者应该重试,后者应该去问用户。

还有一条实测过的坑:测试密钥可能把一个已分配的编码报成未分配。所以在依据 False 做任何动作之前,务必换成正式密钥。

第六步:按前缀路由

因为每一段都在收窄上一段,编码的前缀就代表一个区域:第一段是州,前两段是地方政府区域,依此类推直到楼宇。于是路由退化成一次字典查找。

from ng_postcode import Postcode, Segment, parse

DEPOTS = {"EK-01": "depot-a", "LA-05": "depot-b"}  # 示例名称

def depot(code: Postcode) -> str:
    return DEPOTS.get(code.prefix(Segment.LGA), "national-sorting")

code = parse("EK-01-A03-FK-01")
assert isinstance(code, Postcode)
print(depot(code))  # depot-a

路由表是你自己拥有、可以评审的数据。模型在这一步没有任何参与,同一个编码永远到达同一个站点。未知前缀会落到一个具名的默认值上,所以不会因为表不完整而丢件。

反过来想:如果让模型根据自由文本地址来选目的地,同一个客户在不同日期可能被分到不同站点。这个选择必须留在代码里。

第七步:把三步串成一个函数

解析、确认、路由可以组合成一个函数:输入是模型抽出的字符串,输出要么是站点,要么是下一步动作。它只对已经解析成功的输入调用官方接口。

import os
from ng_postcode import Postcode, Segment, parse
from ng_postcode.api import ApiError, lookup
from ng_postcode.client import Client, TransportError

DEPOTS = {"EK-01": "depot-a", "LA-05": "depot-b"}  # 示例名称

def dispatch(text: str, client: Client) -> str:
    code = parse(text)
    if not isinstance(code, Postcode):
        return f"ask the user: {code}"
    match client.send(lookup(code, level=1)):
        case ApiError() | TransportError() as failure:
            return f"retry later: {failure}"
        case found if not found.valid:
            return f"ask the user: {code} is not assigned to a building"
        case _:
            return DEPOTS.get(code.prefix(Segment.LGA), "national-sorting")

with Client(api_key=os.environ["NG_POSTCODE_API_KEY"]) as client:
    for text in ["100001", "ek 01 a03 fk 01"]:
        print(dispatch(text, client))

输出:

ask the user: expected 11 letters and digits, found 6
ask the user: EK-01-A03-FK-01 is not assigned to a building

每个返回值都指明了下一步:向用户提问、稍后重试,或者一个站点。模型只贡献了 text 这一个参数,别的什么都没参与。如果希望提问里带上纠正建议,把前面闸门里的 gate 换到 parse 的位置即可。

一个完整示例

把上面的片段拼起来,就是一个从原始文本到最终去向的最小可运行流程。它不依赖模型,也不依赖任何 Agent 框架。

import os
from ng_postcode import Corrected, Postcode, Segment, parse, parse_lenient
from ng_postcode.api import ApiError, lookup
from ng_postcode.client import Client, TransportError

DEPOTS = {"EK-01": "depot-a", "LA-05": "depot-b"}  # 示例名称

def gate(text: str) -> Postcode | str:
    match parse(text):
        case Postcode() as code:
            return code
        case error:
            match parse_lenient(text):
                case Corrected(postcode=fixed):
                    return f"refused: {error}. Ask the user: did you mean {fixed}?"
                case _:
                    return f"refused: {error}"

def dispatch(text: str, client: Client) -> str:
    result = gate(text)
    if not isinstance(result, Postcode):
        return f"ask the user: {result}"
    match client.send(lookup(result, level=1)):
        case ApiError() | TransportError() as failure:
            return f"retry later: {failure}"
        case found if not found.valid:
            return f"ask the user: {result} is not assigned to a building"
        case _:
            return DEPOTS.get(result.prefix(Segment.LGA), "national-sorting")

with Client(api_key=os.environ["NG_POSTCODE_API_KEY"]) as client:
    for text in ["ek 01 a03 fk 01", "100001", "EK-O1-A03-FK-01"]:
        print(dispatch(text, client))

运行结果依次是:第一个输入被规范化后进入路由,落到 depot-a;第二个输入因为位数不对被拒绝;第三个输入因为字母 O 出现在数字位被拒绝,并附带一条待用户确认的纠正建议。整条链路上,模型只负责把 ek 01 a03 fk 01 从消息里抠出来,其余判断全部由代码完成。

把工具交给 Agent 时如何施加硬性限制

有些场景下 Agent 确实需要直接操作编码。这时应该像给微服务写接口文档一样,给它一套带书面契约的工具。MCP 服务器默认暴露五个工具,兼容任何 MCP 客户端。客户端配置形如:

{
  "mcpServers": {
    "ng-postcode": {
      "command": "uvx",
      "args": ["ng-postcode-mcp"],
      "env": {
        "NG_POSTCODE_API_KEY": "nipost_live_..."
      }
    }
  }
}

五个默认工具的分工与成本:

工具是否联网是否需要密钥成本
validate_postcode否否免费
lookup_postcode是是免费,固定 level 1
autocomplete_postcode是是免费额度内
find_postcode_at_location是是免费额度内
resolve_address是(含地理编码服务)是免费额度内
lookup_postcode_details是是每次调用消耗额度;仅当 NG_POSTCODE_MAX_LEVEL 设为 2 或更高时才注册

服务器强制三条限制,无论模型决定做什么都绕不过去:

  • 五个默认工具全部只读,不会产生副作用。
  • 密钥只从环境变量读取,永远不作为工具参数出现,所以模型看不到它,也无法把它复述出去。
  • 付费查询属于第六个工具 lookup_postcode_details,只有在你把 NG_POSTCODE_MAX_LEVEL 提到 2 或以上之后,服务器才会注册它。level 2 会返回房屋地址并消耗额度。默认配置下,模型根本没有这个工具可用。

这三条限制的意义在于:它们不依赖提示词,也不依赖模型的配合,而是由服务器在注册阶段就决定好的。

注意事项

下面这些限制和坑,动手前值得逐条确认。

  • 项目规则不是保险。它能改善编码 Agent 的输出,但可能缺失、可能被忽略、可能过期,而且对运行时模型完全无效。真正的保障是确定性代码。
  • 纠正建议不能自动生效。parse_lenient 给出的是猜测,必须经用户确认后才能进入数据库或触发付费调用。
  • 查询失败与未分配必须分开。前者返回 unconfirmed 前缀,应当重试;后者应当去问用户。混在一起会导致把网络抖动当成数据错误。
  • 测试密钥不可信。它可能把已分配的编码报成未分配,依据 False 做决策前请换正式密钥。
  • 包名有同名陷阱。npm 上的 ng-postcode 是另一个项目,安装前请核对。
  • 付费层级默认关闭。level 2 及以上才会返回房屋地址并消耗额度,需要显式提高 NG_POSTCODE_MAX_LEVEL 才会注册对应工具。
  • 路由表要留默认值。未知前缀落到具名默认站点,避免因为表不完整而丢件。
  • 不要让模型选目的地。基于自由文本地址的路由会让同一客户在不同日期到达不同站点。
  • 版本、额度与计费规则会变。本文涉及的包版本、免费额度、付费规则、接口层级,请以官网当前信息为准。

最后要说清楚这套方案不解决什么。它不能阻止模型在自然语言回复里继续描述旧格式——你能约束的只是进入系统的数据。它也不能替代人工确认,凡是涉及用户真实地址的场景,纠正建议依然要回到用户面前。它更不适用于结构本身就不严格的编码体系:这套设计成立的前提是编码有明确的分段规则,每一段都能离线校验。如果你的目标格式没有这种结构,闸门能做的就只剩下长度和字符集检查了。