← 返回
IT技术

如何阻止 AI Agent 编造尼日利亚邮编

✍️ zhirenhun 📅 2026/10/15 👁 146 阅读 ⏱ 36 分钟
如何阻止 AI Agent 编造尼日利亚邮编

2026年10月1日,尼日利亚的邮政编码从六位数字变为能够唯一标识单栋建筑的11字符代码,例如 EK-01-A03-FK-01。

在此日期之前训练的语言模型仍会充满信心地描述旧格式。这会影响两类智能体:一种是编写验证码的编码智能体,另一种是从客户消息中读取邮政编码或填写送货表单的运行时智能体。

本文将展示如何同时应对这两种情况。对于编码智能体,你将测量它今天的输出,并给它一个能够纠正它的规则。对于运行时智能体,你将构建一个边界:模型提出一个邮政编码,确定性代码判断其格式是否正确、NIPOST 是否已分配该编码以及后续工作应流向何处。

你还将看到智能体可以直接调用的工具以及所有失败状态的表格。示例使用我维护的开源库 ng-postcode 及其 MCP 服务器。

目录

前置条件

你需要 Python 3.10 或更高版本。除实时查询外,其他内容均可离线运行,无需密钥。

对于查询,你可以从 NIPOST 开发者仪表盘 获取密钥。MCP 部分需要 uv,它提供 uvx 命令,以及一个 MCP 客户端,如 Claude Code 或 Cursor。

pip install "ng-postcode[client]"

编码智能体今天说了什么

我向编码智能体提出了开发者会问的问题。为了保证测试公平,我从一个空文件夹运行它,这样智能体就没有项目文件可读,并且我允许它搜索网络。

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:  / FORMAT_ASSUMED:  / SEARCHED: . \
Today is 5 October 2026." --allowedTools "WebSearch,WebFetch"

我在 2026 年 10 月 5 日分别对 Python、JavaScript、Rust 和 Java 运行了它。以下是每个答案的最后几行:

语言 库 假设的格式 已搜索
Python 无 恰好六位数字,例如 100001 否
JavaScript 无 六位数字,首位为 1 到 9 否
Rust 无 恰好六位 ASCII 数字 否
Java 无 六位数字,首位为 1 到 9 否

四种语言得出了相同的答案。Agent 拥有一个搜索工具,且其提示词中包含了日期,但它仍然凭记忆写出了一个六位数模式,而未进行搜索。

这是一次对单个 Agent 在每种语言上仅测量一次、仅在一天内进行的实验,你可以用自己的工具重复得到这个读数。

其背后的机制是通用的。模型会复刻其训练数据中占主导的格式,而新格式在那几乎没有出现。它生成的代码随后会拒绝所有当前真实的邮编:

import re

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

如何在源头纠正编码智能体

该智能体因缺少一个事实而写出了六位数模式,只需补充该事实。将此粘贴到你的项目的 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.
Reference: https://adeniyikayodee.github.io/ng-postcode/llms.txt

2026年10月13日,我在仅包含此规则的CLAUDE.md文件所在文件夹中再次运行了Python提示。答案以以下几行结束:

LIBRARY: ng-postcode
FORMAT_ASSUMED: 11-character NDAPS digital postcode, e.g. EK-01-A03-FK-01
SEARCHED: yes

该规则能提升编码 Agent 的输出质量。它也可能缺失、被忽略或过时,对在运行时处理邮政编码的模型没有影响。本文剩余部分将构建在这些情况下仍然有效的检查。

Where to Draw the Boundary

在运行时处理邮政编码的模型是一个具有已知属性的组件:对相同的输入,它可能返回不同的输出,其中一些看起来正确但实际上是错误的。您已经在设计中考虑了这类组件。网络可能会丢弃数据包,于是您添加了校验和。用户可能会误填字段,于是您添加了验证。模型也属于同一类。

每个组件承担一项任务。模型负责填充有效载荷:它从消息中读取 "my code is ek 01 a03 fk 01" 并提取该字符串。解析器判断该字符串是否为邮政编码。NIPOST API 判断该邮政编码是否分配给某栋建筑。您的代码读取前缀后,决定哪个仓库或队列接收该任务。这些决策均不以模型的置信度作为依据。

这张图展示了一个语言模型的工作流程,包括解析器、注册表和路由表的使用

信任边界:模型提供有效载荷,虚线之后的每个决策和每个失败退出均由确定性代码处理。

邮政编码适合此设计,因为其结构严格。它由五段组成,每一段都在缩小前一段的范围。

段 示例 规则
州 EK 两个字母
地方政府区域 01 两位数字,01 到 99
区 A03 三个字母或数字
地区 FK 两个字母
建筑单元 01 两位数字,01 到 99

在大多数位置,一个错误的字符会违反代码可离线检查的规则,无需网络调用也不需要模型。

代码的每个前缀都对应一个更小的区域,从州一直到单栋建筑。

How to Build the Gate

门禁是一个函数,模型生成的每个邮政编码在系统其他部分看到它之前都会通过它。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
refused: expected 11 letters and digits, found 6
refused: invalid lga segment
refused: invalid lga segment. Ask the user: did you mean EK-01-A03-FK-01?

系统接受第一个输入并进行归一化。第二个输入是旧的六位数代码,网关会指出它哪里出错。第三个输入的地方政府编号为 00,系统会排除这种格式。

第四个输入中,字母 O 出现在只能放数字的位置,而 parse_lenient 能够猜出用户的本意。网关仍然拒绝该输入,并以问题的形式返回建议供用户确认。因为更正只是猜测,所以在用户确认之前,它不应进入数据库或触发付费 API 调用。

如何使用 NIPOST 确认代码

网关可以验证字符串格式是否正确。NIPOST 是否已将该代码分配给某栋建筑是另一个问题,一次一级查询就能免费得到答案。在运行之前,请将您的密钥设置为环境变量 NG_POSTCODE_API_KEY。

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 是 NIPOST 文档中的示例,且不属于任何建筑,因此答案是 False。被拒绝的密钥或连接失败会返回以 unconfirmed 开头的字符串,这能够将未成功的查询与 NIPOST 未分配的代码区分开来。NIPOST 的测试密钥可能会将已分配的代码报告为未分配,因此在根据 False 采取行动前,请先使用正式密钥。

如何按前缀路由工作

因为每个片段都在缩小范围,代码的前缀命名了一个区域:第一段是州,前两段是地方政府区域,一直到建筑,这样路由就变成了字典查找。

from ng_postcode import Postcode, Segment, parse

DEPOTS = {"EK-01": "depot-a", "LA-05": "depot-b"}  # invented names


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

路由表是你拥有且可以查看的数据。模型在此步骤中不参与,相同的代码总是到达同一个仓库。未知的前缀会落到一个命名的默认值,因此即使路由表不完整,任务也不会丢失。

如果让模型从自由文本地址中选择目的地,同一客户在不同日期可能到达不同的仓库,因此应将此选择保留在代码中。

如何将步骤组合在一起

这三个步骤组合成一个函数。它接收模型提取的字符串,并返回一个仓库或下一步操作,仅对已解析的输入调用 NIPOST。

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"}  # invented names


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 提供带强制限制的工具

当 Agent 确实需要直接处理邮政编码时,应为其提供带有书面合同的工具,就像编写微服务文档那样。默认情况下,ng-postcode-mcp 服务器通过 Model Context Protocol 暴露五个工具,并且可以与任何 MCP 客户端一起工作。

五个默认工具在 MCP 客户端看来如下,此处展示为 Glama 目录列表。

{
  "mcpServers": {
    "ng-postcode": {
      "command": "uvx",
      "args": ["ng-postcode-mcp"],
      "env": { "NG_POSTCODE_API_KEY": "nipost_live_..." }
    }
  }
}
工具 网络 需要密钥 费用
validate_postcode 无 否 免费
lookup_postcode NIPOST 是 免费,始终为级别 1
autocomplete_postcode NIPOST 是 免费层级
find_postcode_at_location NIPOST 是 免费层级
resolve_address NIPOST 和一个地理编码器 是 免费层级
lookup_postcode_details NIPOST 是 每次调用都消耗积分。仅在 NG_POSTCODE_MAX_LEVEL 为 2 或更高时注册。

服务器施加三个限制,因而它们将保持模型决定的任何行为。

  1. 五个默认工具是只读的。

  2. 密钥从环境变量中读取。它从来不是工具参数,因此模型永远看不到它,也不能重复它。

  3. 付费查询属于第六个工具 lookup_postcode_details,只有在你将 NG_POSTCODE_MAX_LEVEL 提升到 2 或更高时,服务器才会注册它。

级别 2 返回房屋地址并消耗积分。在默认服务器上,模型没有可请求它的工具:lookup_postcode 仅接受级别 1,对付费工具的调用将得到此回复:

Unknown tool: lookup_postcode_details

当你提高上限时,付费工具会在没有只读提示的情况下出现;即便客户端自动批准只读工具,在消耗积分前仍会向你确认。服务器在一次运行中完成 25 次付费查询后也会停止,除非你调高 NG_POSTCODE_MAX_PAID_CALLS。超过上限的请求将返回如下内容:

Level 3 is above this server's cap of 2. Levels 2+ consume NIPOST credits; the user can
raise NG_POSTCODE_MAX_LEVEL to allow it.

权限保存在用户设置的配置中,任何提示词都无法修改它。

如何处理每种故障状态

假设模型会产生错误代码、密钥过期以及网络故障。下表列出每种状态、检测该状态的组件以及 agent 收到的内容。每条消息都是 2026 年 10 月 13 日记录的服务器 0.6.2 版本的输出。

状态 检测组件 返回内容
旧六位代码 解析器,离线 expected 11 letters and digits, found 6
段落超出范围 解析器,离线 invalid lga segment
相似拼写错误发送至查询 服务器,在任何网络调用之前 'EK-O1-A03-FK-01' is not a valid postcode: invalid lga segment. Did you mean EK-01-A03-FK-01? Confirm with the user first.
格式正确但从未分配 NIPOST "valid": false, "status": "not_found"
未配置密钥 服务器 This tool needs a NIPOST API key
密钥被拒绝 NIPOST invalid_api_key (401), 以及创建新密钥的位置
发送至 lookup_postcode,等级高于 1 服务器 Input should be less than or equal to 1
等级超过服务器上限 服务器 上面所示上限消息
密钥缺少等级 NIPOST level_not_granted (403)
API 不可达 HTTP 客户端 Could not reach the NIPOST API: All connection attempts failed

这些检查按此顺序运行,前三项不产生费用,因为它们在任何请求发送前就完成了。

第三行是相似拼写错误。服务器发现了可能的纠正并拒绝使用它。它未发送任何查询,且其回复告诉模型需要向用户确认。如果系统在纠正代码后继续执行,将返回用户从未命名的建筑真实地址。

输入错误的代码会在任何请求发出前被拒绝,而纠正后的代码仅在用户确认后才会到达 NIPOST。

第四行是幻觉代码。模型可以生成符合所有格式规则且不属于任何建筑的代码,离线验证会接受它。只有注册表能够区分这两种情况,因此需要在级别 1 查找每个重要代码。

HTTP 客户端以三种方式保护每个请求。它为整个交互设置十秒截止时间,这样停滞的连接就不会占用智能体的轮次。它不会跟随重定向,因为重定向会把你的密钥带到另一台主机。出于同样的原因,它会拒绝纯 http:// 基础 URL,除非是在你自己的机器上。

如何解码你不信任的回复

同样的怀疑也适用于 API 的回复。直接读取响应体的智能体会找到一种方式来解释它收到的任何内容。库将工作分离:lookup 将请求构建为纯数据,而 decode 将状态和主体转换为带类型的结果或错误。你可以在无网络的情况下进行测试。

from ng_postcode import Postcode, parse
from ng_postcode.api import ApiError, decode, lookup

code = parse("EK-01-A03-FK-01")
assert isinstance(code, Postcode)
request = lookup(code)

replies = [
    (200, '{"data": {"postcode": "EK-01-A03-FK-01", "valid": false, "status": "not_found"}}'),
    (200, '{"data": {"postcode": "EK-01-A03-FK-01"}}'),
    (401, '{"error": {"code": "invalid_api_key", "message": "the provided API key is invalid or revoked"}}'),
    (502, "Bad Gateway"),
]
for status, body in replies:
    match decode(request, status, body):
        case ApiError() as error:
            print("error:", error)
        case found:
            print("assigned:", found.valid)
assigned: False
error: malformed_response (200): unexpected data
error: invalid_api_key (401): the provided API key is invalid or revoked
error: malformed_response (502): not JSON: Expecting value: line 1 column 1 (char 0)

在第二次回复中,主体是有效的 JSON,状态码为 200,且 valid 字段缺失。宽松的解码器会将缺失的字段读作 false,并报告该建筑不存在。而此实现会报告响应格式错误,因此缺失字段永不会被当作关于建筑的断言。该库在 Rust、Python、JavaScript 和 Java 中的所有实现都通过了与此类似的共享测试用例文件。

这一方案未能解决的问题

一个有效且已分配的邮政编码仍可能是错误的。如果用户提供了邻居的编码,本文中的所有检查都会通过。要确认该编码属于面前的人,需要另一种控制方式,例如向他们展示该编码解析出的地址。

仅凭文本很难唯一确定一栋建筑:例如 “Back of Fabian Hotel, off NTA Road” 只描述了一个区域,而 resolve_address 工具在这种情况下会返回一个区域前缀并向用户提出问题。若需精确定位建筑,应请求提供一个位置标记。

地址属于个人数据:第二级查询会返回详细住址,因此应根据尼日利亚数据保护法处理结果,除非有必要,否则将查询深度限制在第一级。

结论

模型是路径中最不可靠的组件,因此应让它承担最小的任务:提取一个字符串。解析器免费检查结构。注册表确认建筑存在。您自行维护的、以前缀为键的表格决定工作流向。更正会以问题的形式返回给用户,而涉及费用或泄露地址的权限则存放在由个人控制的配置中。

即使模型后来学习了新格式,该边界仍然有用。同样的设计也适用于地址系统、税码或账号的后续变更——模型同样会在这些变更上滞后学习。

该代码在 MIT 许可证下开源,托管于 github.com/Adeniyikayodee/ng-postcode。为您的语言安装该库:

pip install ng-postcode       # Python
npm install ng-postcode-js    # JavaScript and TypeScript
cargo add ng-postcode         # Rust

对于 Java,Maven 坐标是 io.github.adeniyikayodee:ng-postcode-api。如果库在大小写上出错,请提交 issue。

——

广告 广告
🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。