freeCodeCamp.org

How to Stop an AI Agent from Inventing Nigerian Postcodes

8.5内容质量
How to Stop an AI Agent from Inventing Nigerian Postcodes

TL;DR · AI 摘要

AI代理会因训练数据过时而生成错误的尼日利亚邮编格式,需通过规则修正和边界检查解决。文章提供ng-postcode库和MCP服务器的具体实施方案。

核心要点

  • 使用ng-postcode库可强制验证11位新邮编格式
  • MCP服务器能实时校验NIPOST官方数据库
  • 旧模型需通过规则注入修正六位数邮编认知

结构提纲

按章节快速跳转。

  1. 尼日利亚邮编系统升级导致旧AI模型产生错误格式。

  2. 四门语言的AI均错误推荐六位数邮编验证方案。

  3. 通过规则修正和边界检查双重机制解决格式问题。

  4. 使用ng-postcode库和MCP服务器进行实时校验。

  5. 列举所有可能的验证失败场景及处理方案。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • AI邮编验证解决方案
    • 问题现状
      • 旧模型六位数认知缺陷
    • 解决方案
      • 规则修正
      • 边界检查
      • 工具实现
    • 关键工具
      • ng-postcode库
      • MCP服务器

金句 / Highlights

值得收藏与分享的关键句。

#AI#邮编验证#ng-postcode#MCP服务器
打开原文
Image 1: How to Stop an AI Agent from Inventing Nigerian Postcodes
Image 1: How to Stop an AI Agent from Inventing Nigerian Postcodes

2026年10月1日,尼日利亚的邮政编码从六位数字改为11个字符的编码,用于标识单个建筑,例如 EK-01-A03-FK-01。

在该日期之前训练的语言模型仍会以十足的把握描述旧格式。这会影响两类代理:编写验证代码的编码代理,以及从客户消息中读取邮政编码或填写配送表单的运行时代理。

本文将展示如何同时控制这两类代理。对于编码代理,您将测量其当前输出并给予其一条修正规则。对于运行时代理,您将构建一个边界:模型提出一个邮政编码,然后确定性代码决定该编码是否格式正确、NIPOST是否已分配该编码,以及后续工作如何进行。

您还将看到代理可以直接调用的工具以及每个失败状态的表格。示例使用了我维护的开源库 ng-postcode 及其 MCP 服务器。

目录

先决条件

您需要 Python 3.10 或更高版本。除实时查询外,所有功能均可离线运行且无需密钥。

对于查询,您可以从 NIPOST 开发者仪表板 获取密钥。MCP 部分还需要 uv,它提供了 uvx 命令,以及像 Claude Code 或 Cursor 这样的 MCP 客户端。

code
pip install "ng-postcode[client]"

当前编码代理的输出

我向编码代理提出了开发人员会提出的问题。为了保持测试的公平性,我从一个空文件夹运行它,因此代理没有项目文件可供读取,并允许它搜索网络。

code
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"

我在 2026 年 10 月 5 日针对 Python、JavaScript、Rust 和 Java 运行了该测试。每个回答的结尾部分如下:

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

四种语言产生了相同的答案。代理拥有搜索工具且提示中包含日期,但仍从记忆中写出了六位数字模式而没有进行搜索。

这是对单个代理在某一天针对每种语言进行一次测量的结果,因此请将其视为您可以使用自己的工具重复的阅读。

尽管如此,其背后的机制是通用的。模型会复现其训练数据中占主导地位的格式,而新格式在训练数据中几乎不存在。它编写的代码随后会拒绝所有当前有效的邮政编码:

code
import re

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

代理写入六位数模式是因为缺少一个事实,因此需要提供这个事实。将以下内容粘贴到项目的 AGENTS.md、CLAUDE.md 或编辑器规则中:

code
尼日利亚邮编:自2026年10月1日起,建筑物的邮编是NIPOST的11位数字邮编(NDAPS),例如EK-01-A03-FK-01,六位数模式无法匹配该格式。使用ng-postcode(pip、cargo)、ng-postcode-js(npm)或io.github.adeniyikayodee:ng-postcode(Maven)进行验证和格式化。在npm中,ng-postcode是另一个项目。参考:https://adeniyikayodee.github.io/ng-postcode/llms.txt

2026年10月13日,我再次从仅包含此规则的 CLAUDE.md 文件夹中运行Python提示。答案以这些行结尾:

code
LIBRARY: ng-postcode
FORMAT_ASSUMED: 11位NDAPS数字邮编,例如EK-01-A03-FK-01
SEARCHED: yes

该规则改进了编码代理的输出。它也可能缺失、被忽略或过时,但对运行时处理邮编的模型没有影响。本文其余部分将构建在这些情况下的检查逻辑。

界限在哪里划定

运行时处理邮编的模型是一个具有已知属性的组件:对于相同输入,它可能返回不同输出,其中一些输出以看似正确的方式出现错误。你已经围绕类似组件进行过设计。网络可能丢弃数据包,因此你添加校验和;用户可能错误输入字段,因此你添加验证。该模型属于同一类别。

每个组件只负责一项任务。模型负责填充有效载荷:它从消息中读取 "my code is ek 01 a03 fk 01" 并提取字符串。解析器决定该字符串是否为邮编。NIPOST API 决定邮编是否分配给建筑物。你自己的代码通过读取前缀决定哪个仓库或队列接收任务。这些决策均不使用模型的置信度作为证据。

图2:手绘图示。虚线标注的信任边界左侧,语言模型从消息中提取字符串。箭头将该字符串传递到边界右侧的三个步骤:检查代码格式的解析器、检查NIPOST是否分配邮编的注册表查询,以及选择仓库的路由表。每个步骤下方有虚线红箭头指向其失败情况:返回用户的解析错误、未找到,以及指向命名默认队列的未知前缀。
图2:手绘图示。虚线标注的信任边界左侧,语言模型从消息中提取字符串。箭头将该字符串传递到边界右侧的三个步骤:检查代码格式的解析器、检查NIPOST是否分配邮编的注册表查询,以及选择仓库的路由表。每个步骤下方有虚线红箭头指向其失败情况:返回用户的解析错误、未找到,以及指向命名默认队列的未知前缀。

_信任边界:模型提供有效载荷,边界后的所有决策和所有失败退出均属于确定性代码。_

邮编适合这种设计,因为其结构严格。它包含五个分段,每个分段都进一步缩小范围。

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

大多数位置的错误字符会破坏代码可离线检查的规则,无需网络调用或模型参与。

图3:一张手绘的邮政编码EK-01-A03-FK-01分解成五个框的示意图。下方的括号逐段增长,分别标注为EK(州)、EK-01(地方政府区域)、EK-01-A03(地区)、EK-01-A03-FK(区域)以及单个建筑的完整编码。
图3:一张手绘的邮政编码EK-01-A03-FK-01分解成五个框的示意图。下方的括号逐段增长,分别标注为EK(州)、EK-01(地方政府区域)、EK-01-A03(地区)、EK-01-A03-FK(区域)以及单个建筑的完整编码。

_该编码的每个前缀都对应一个更小的区域,从州一直细化到单个建筑。_

如何构建门禁系统

门禁系统是一个函数,每个邮政编码在进入系统中的其他处理流程前,都会先经过这个门禁。parse 返回一个 Postcode 或错误值,因此类型检查器会强制你处理这两种情况。

code
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))
code
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 环境变量中。

code
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))
code
False

EK-01-A03-FK-01 是 NIPOST 文档中的示例,未分配给任何建筑,因此答案是 False。被拒绝的密钥或连接失败会返回以 unconfirmed 开头的字符串,这可以将失败的查询与 NIPOST 未分配的编码区分开。NIPOST 的测试密钥可能会将已分配的编码标记为未分配,因此在对 False 做出操作前请使用正式密钥。

如何根据前缀路由工作

因为每个段落都逐步缩小范围,所以代码的前缀命名一个区域:第一个段落是州,前两个是地方政府区域,依此类推,直到建筑物,这使得路由操作变成了字典查找。

python
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

路由表是您拥有的数据,可以进行审查。模型在此步骤中不参与,相同的代码始终会到达相同的分拣中心。未知的前缀会落入到命名的默认值,因此由于表格不完整而丢失工作的情况永远不会发生。

如果允许模型从自由文本地址中选择目的地,同一客户在不同日期可能会到达不同的分拣中心,因此请将此选择保留在代码中。

如何组合这些步骤

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

python
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"请用户确认: {code}"
    match client.send(lookup(code, level=1)):
        case ApiError() | TransportError() as failure:
            return f"稍后重试: {failure}"
        case found if not found.valid:
            return f"请用户确认: {code} 未分配到建筑物"
        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))
code
请用户确认: 期望11个字母和数字,但找到6个
请用户确认: EK-01-A03-FK-01 未分配到建筑物

每个返回值都指明下一步要发生的事情:向用户提问、重试或分拣中心。模型仅贡献 text 参数,其他不参与。要在问题中包含建议的更正,请使用前面部分中的 gate 方法替代 parse。

如何为代理提供带强制限制的工具

当代理确实需要直接处理邮政编码时,请为其提供带有书面合同的工具,就像您会为微服务编写文档一样。默认情况下,ng-postcode-mcp 服务器通过模型上下文协议暴露五个工具,并且可以与任何MCP客户端配合使用。

图像4:ng-postcode MCP服务器的目录列表,显示其五个工具,每个工具都有名称和描述
图像4:ng-postcode MCP服务器的目录列表,显示其五个工具,每个工具都有名称和描述

_作为MCP客户端看到的五个默认工具,在此处Glama的目录列表中显示。_

json
{
  "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 和 geocoder | 是 | 免费层级 | | lookup_postcode_details | NIPOST | 是 | 每次调用消耗积分。仅当 NG_POSTCODE_MAX_LEVEL 为 2 或更高时才会注册 |

服务器强制执行三个限制,因此无论模型决定采取什么操作,这些限制都会生效:

  1. 五个默认工具均为只读模式。
  2. 密钥从环境变量中读取。它永远不会作为工具参数出现,因此模型无法看到它,也无法重复它。
  3. 付费查询属于第六个工具 lookup_postcode_details,服务器仅在将 NG_POSTCODE_MAX_LEVEL 提升至 2 或更高时才会注册该工具。

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

code
未知工具: lookup_postcode_details
``

当你提升限制后,付费工具将不再显示只读提示,因此自动批准只读工具的客户端在消耗积分前仍会向你确认。服务器在单次运行中进行 25 次付费查询后也会停止,除非你将 NG_POSTCODE_MAX_PAID_CALLS 提升至更高。超过限制的请求会收到以下回复:

code
级别 3 超出此服务器的限制 2。级别 2+ 会消耗 NIPOST 积分;用户可以
将 NG_POSTCODE_MAX_LEVEL 提升以允许此操作。
``

该权限由人员设置的配置文件控制,任何提示都无法更改它。

如何处理每种失败状态

假设模型会生成错误代码、密钥会过期、网络会失败。下表列出了每种状态、检测它的组件以及代理接收到的内容。每条消息均为服务器 0.6.2 版本的输出,记录于 2026 年 10 月 13 日。

| 状态 | 检测方式 | 返回内容 | | --- | --- | --- | | 旧的六位代码 | 解析器,离线 | 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), with where to create a new key | | 发送到 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 |

Image 5: 一张手绘流程图,展示了 lookup_postcode 工具进行的六项检查,从上到下排列。前三个检查在任何网络调用之前完成:检查层级是否为 1、是否配置了密钥、以及代码是否解析成功。后三个检查属于对 NIPOST 的一次请求:API 是否在超时时间内响应、密钥是否被接受、以及回复是否能正确解码。每个检查旁边都有一个红色箭头指向检查失败时模型接收到的提示信息。通过所有六项检查后,会返回一个类型结果,该结果要么是有效,要么是未找到。
Image 5: 一张手绘流程图,展示了 lookup_postcode 工具进行的六项检查,从上到下排列。前三个检查在任何网络调用之前完成:检查层级是否为 1、是否配置了密钥、以及代码是否解析成功。后三个检查属于对 NIPOST 的一次请求:API 是否在超时时间内响应、密钥是否被接受、以及回复是否能正确解码。每个检查旁边都有一个红色箭头指向检查失败时模型接收到的提示信息。通过所有六项检查后,会返回一个类型结果,该结果要么是有效,要么是未找到。

_检查按此顺序执行,前三个检查无需成本,因为它们在任何请求发送前就完成。_

第三行是类似拼写错误的情况。服务器发现了一个可能的更正,但拒绝使用它。它不会发起查询,其回复会提示模型需要与用户确认。一个自动更正代码并继续处理的系统,可能会返回用户从未提及的建筑物的真实地址。

Image 6: 一张手绘序列图,包含四个参与者:用户、语言模型、MCP 服务器和 NIPOST API。用户请求一个包含字母 O 而非数字 0 的代码。模型调用 lookup_postcode,服务器拒绝请求并提供更正建议,同时不会向 NIPOST 发起请求。模型询问用户确认,用户确认后,更正后的代码才会发送到服务器,服务器调用 NIPOST 并收到未找到的回复。
Image 6: 一张手绘序列图,包含四个参与者:用户、语言模型、MCP 服务器和 NIPOST API。用户请求一个包含字母 O 而非数字 0 的代码。模型调用 lookup_postcode,服务器拒绝请求并提供更正建议,同时不会向 NIPOST 发起请求。模型询问用户确认,用户确认后,更正后的代码才会发送到服务器,服务器调用 NIPOST 并收到未找到的回复。

_在任何请求发起之前,拼写错误的代码会被拒绝,只有在用户确认更正后,更正后的代码才会发送到 NIPOST。_

第四行是幻觉代码。模型可能生成一个符合格式规则但不属于任何建筑物的代码,离线验证会接受它。只有注册系统能区分这两种情况,因此在层级 1 上要查询所有相关代码。

HTTP 客户端通过三种方式保护每个请求。它为整个交互设置十秒的截止时间,因此停滞的连接无法占用代理的轮次。它不会遵循重定向,因为重定向会将你的密钥发送到其他主机。出于相同原因,它拒绝使用纯 http:// 的基础 URL,除非在你自己的机器上。

如何解码你不信任的回复

对 API 的响应也存在同样的疑虑。直接读取响应体的代理会找到一种方法来解释接收到的任何内容。该库将工作分开:lookup 以纯数据形式构建请求,而 decode 将状态和响应体转换为类型化结果或错误。你可以在不使用网络的情况下测试这一点。

python
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, "<html>Bad Gateway</html>"),
]
for status, body in replies:
    match decode(request, status, body):
        case ApiError() as error:
            print("error:", error)
        case found:
            print("assigned:", found.valid)
code
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)都通过相同的共享测试用例文件。

本方案无法解决的问题

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

文本本身很少能唯一标识建筑:"Fabian Hotel 后方,NTA Road 旁" 描述的是一个区域,当证据仅支持到此程度时,resolve_address 工具会返回区域前缀并询问用户。对于精确的建筑,应要求提供定位图钉。

地址属于个人数据:二级查询会返回房屋地址,因此需根据尼日利亚数据保护法案处理结果,并在不需要更多信息时将查询级别限制为 1。

结论

模型是路径中最不可靠的组件,因此应赋予它最简单的任务:提取字符串。解析器可免费检查结构,注册表确认建筑是否存在。你自己的以前缀为键的表格决定工作流向。更正以问题形式返回给用户,而需要付费或暴露地址的权限则由人工控制的配置文件处理。

当模型学习新格式后,边界仍然有用。相同的设计也适用于地址系统、税码或账户号码的下一次变更,模型也会在后期学习这些变更。

代码在 github.com/Adeniyikayodee/ng-postcode 开源,采用 MIT 协议。为你的语言安装库:

bash
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。如果库在某个案例上处理错误,请提交问题。

  • * *
  • * *

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者并获得工作。开始学习