读见

如何防止 AI 智能体虚构尼日利亚邮政编码

原题How to Stop an AI Agent from Inventing Nigerian Postcodes

约 12 分钟约 2,989 字原文英文已翻译

推荐理由模型提出候选,确定性代码与权威查询决定是否采用。读者可用邮编案例学习把变化快的业务规则放到可校验的工具边界。

读前速览

4 个要点

本文通过通过实施确定性验证边界、MCP 服务器和严格规则执行,防止 AI 智能体幻幻尼日利亚邮政编码。

  1. LLM 受过时训练数据的影响

    模型往往会自信地生成过时的格式(如六位邮政编码),因为这些格式在训练数据集中占主导地位,导致期望新格式的逻辑出现静默失败。

  2. 实施「信任边界」

    AI 仅用于从用户输入中提取原始字符串;必须性性确定性代码判断这些字符串格式正确且有效。

  3. 使用 MCP 控制工具访问

    通过使用模型上下文协议服务器,开发者可以对 API 调用和参数实施严格限制,确保 AI 不绕过业务逻辑或耗尽额度。

  4. 确定性路由优于概率选择

    基于验证过的邮编前缀使用本地路由表,可以确保包裹始终到达正确的网纽,无论模型的变异性如何。

完整摘要

2026 年 10 月,尼日利亚将其邮政系统从六位数字更新为 11 位字母数字格式。许多 AI 模型仍然依赖过时的训练数据。作者提供了一种稳健的工程模式来处理这个问题:使用「信任边界」,由 AI 提取字符串,而确定性代码负责验证、官方 NIPOST 注册校验以及路由。指南介绍了如何使用模型上下文协议(MCP)实施限制,防止模型进行未经授权的 API 调用或生成错误数据。

目录 · 14 节
查看原文

2026年10月1日,尼日利亚的邮政编码从六位数字改为一种11字符代码,可用于识别单栋建筑,例如 EK-01-A03-FK-01。On 1 October 2026, Nigeria's postcode changed from six digits to an 11-character code that identifies a single building, such as EK-01-A03-FK-01.

在那个日期之前训练的语言模型,仍会信心十足地描述旧格式。这会影响两类智能体:一种是编写验证代码的编程智能体,另一种是在运行时从客户消息中读取邮政编码或填写配送表单的智能体。Language models trained before that date still describe the old format with full confidence. That affects two kinds of agent: a coding agent that writes your validation code, and a runtime agent that reads a postcode from a customer's message or fills in a delivery form.

本文将介绍如何约束这两者。对于编码智能体,你将衡量它当前的输出,并为它制定一条纠正规则。对于运行时智能体,你将建立一道边界:由模型提出一个邮政编码,再由确定性代码判断其格式是否正确、NIPOST 是否已为其分配,以及接下来将任务交由何处处理。This article shows you how to contain both. For the coding agent, you'll measure what it says today and give it a rule that corrects it. For the runtime agent, you'll build a boundary: the model proposes a postcode, and deterministic code decides whether it's well formed, whether NIPOST has assigned it, and where the work goes next.

你还会看到智能体可以直接调用的工具,以及列出所有失败状态的表格。示例使用的是 ng-postcode(我维护的一个开源库)及其 MCP 服务器。You'll also see the tools an agent can call directly and a table of every failure state. The examples use ng-postcode, an open-source library I maintain, and its MCP server.

目录Table of Contents

先决条件Prerequisites

你需要使用 Python 3.10 或更高版本。除实时查询外,所有操作都可离线运行,无需密钥。You'll need Python 3.10 or later. Everything except the live lookups runs offline and needs no key.

对于查找操作,您可以从 NIPOST 开发者仪表板获取密钥。MCP 部分还需要 uv(它提供 uvx 命令)以及 MCP 客户端,例如 Claude Code 或 Cursor。For the lookups, you can get a key from the NIPOST developer dashboard. The MCP section also needs uv, which provides the uvx command, and an MCP client such as Claude Code or Cursor.

pip install "ng-postcode[client]"

编程智能体今天的说法What a Coding Agent Says Today

我向一个编程智能体提出了开发者会问的问题。为了保证测试公平,我在一个空文件夹中运行了它,因此智能体没有任何项目文件可供读取,而且我允许它搜索网页。I asked a coding agent the question a developer would ask. To keep the test fair, I ran it from an empty folder, so the agent had no project files to read, and I allowed it to search the web.

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 运行了它。以下是每个回答的结尾部分:I ran it on 5 October 2026 for Python, JavaScript, Rust, and Java. These are the closing lines of each answer:

Language Library Format assumed Searched
Python none exactly six digits, e.g. 100001 no
JavaScript none six digits, first digit 1 to 9 no
Rust none exactly six ASCII digits no
Java none six digits, first digit 1 to 9 no

四种语言给出了相同的答案。该智能体可以使用搜索工具,提示词中也提供了日期,但它仍然凭记忆写出了一个六位数模式,没有进行搜索。Four languages produced the same answer. The agent had a search tool and the date in its prompt, and it still wrote a six-digit pattern from memory without searching.

这是同一位智能体在某一天对每种语言各测量一次的结果,因此请将其视为可用自己的工具重复验证的一项读数。This is one agent, measured once per language on one day, so treat it as a reading you can repeat with your own tools.

不过,其背后的机制具有普遍性。模型会复现训练数据中占主导地位的格式,而新格式在训练数据中几乎不存在。因此,它编写的代码会拒绝所有当前有效的真实邮政编码:The mechanism behind it is general, though. A model reproduces the format that dominated its training data, and the new format had almost no presence there. The code it writes then rejects every current real postcode:

import re

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

如何从源头纠正编码代理How to Correct the Coding Agent at the Source

代理写下了六位数字模式,因为它缺少一条信息,所以请把这条信息告诉它。将以下内容粘贴到项目的 AGENTS.md、CLAUDE.md 或编辑器规则中:The agent wrote the six-digit pattern because it lacked one fact, so give it that fact. Paste this into your project's AGENTS.md, CLAUDE.md, or editor rules:

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日,我在一个文件夹中再次运行了 Python 提示词,该文件夹里只有 CLAUDE.md 文件中的这条规则。回答以以下几行结尾:On 13 October 2026 I ran the Python prompt again from a folder that held only this rule in a CLAUDE.md file. The answer ended with these lines:

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

这条规则能改进编码智能体生成的内容。但它也可能缺失、被忽略或已经过时,而且对运行时处理邮政编码的模型毫无影响。本文余下部分将构建一项能在这些情况下发挥作用的检查。The rule improves what a coding agent writes. It can also be missing, ignored, or out of date, and it has no effect on a model that handles postcodes at run time. The rest of this article builds the check that holds in those cases.

边界应划在哪里Where to Draw the Boundary

在运行时处理邮政编码的模型,是一种具有已知特性的组件:对于相同的输入,它可能返回不同的输出,其中一些虽然看起来正确,实际上却是错的。你已经会针对这类组件进行设计。网络可能会丢弃数据包,因此你会添加校验和。用户可能会输错字段,因此你会添加验证。模型也属于同一类。A model that handles postcodes at run time is a component with a known property: for the same input it can return different outputs, and some of them are wrong in ways that look right. You already design around components like that. A network can drop a packet, so you add a checksum. A user can mistype a field, so you add validation. The model belongs in the same category.

每个组件只负责一项工作。模型负责提取内容:它读取消息中的“my code is ek 01 a03 fk 01”,并提取出这串字符。解析器判断这串字符是否为邮政编码。NIPOST API 判断该邮政编码是否分配给某栋建筑。你自己的代码通过读取前缀,决定这项任务应交由哪个仓库或队列处理。这些判断都不会将模型的置信度作为依据。Each component gets one job. The model fills in the payload: it reads "my code is ek 01 a03 fk 01" in a message and extracts the string. A parser decides whether that string is a postcode. The NIPOST API decides whether the postcode is assigned to a building. Your own code, reading a prefix, decides which depot or queue receives the job. None of these decisions uses the model's confidence as evidence.

A hand-drawn diagram. On the left of a dashed line labeled trust boundary, a language model extracts a string from the message. An arrow carries that string across the line to three steps in order: a parser that checks the code is well formed, a registry lookup that checks NIPOST has assigned it, and a routing table that chooses a depot. A dashed red arrow drops from each step to its failure: a parse error that goes back to the user, not found, and an unknown prefix that goes to a named default queue.

信任边界:模型提供载荷,而虚线之后的所有决策和所有失败退出都由确定性代码负责。The trust boundary: the model supplies the payload, and every decision and every failure exit after the dashed line belongs to deterministic code.

邮政编码适合这种设计,因为它的结构很严格。它由五个部分组成,每一部分都比前一部分范围更小。The postcode suits this design because its structure is strict. It has five segments, and each one narrows the one before it.

Segment Example Rule
State EK Two letters
Local government area 01 Two digits, 01 to 99
District A03 Three letters or digits
Area FK Two letters
Building unit 01 Two digits, 01 to 99

大多数位置上的错误字符都会违反一条可离线检查的规则,无需调用网络,也无需模型。A wrong character in most positions breaks a rule that code can check offline, with no network call and no model.

A hand-drawn diagram of the postcode EK-01-A03-FK-01 split into five boxes. Brackets beneath it grow by one segment at a time, labeled EK for the state, EK-01 for the local government area, EK-01-A03 for the district, EK-01-A03-FK for the area, and the full code for one building.

代码名称中的每个前缀都代表一个更小的区域,从州一直到单栋建筑。Each prefix of the code names a smaller region, from a state down to a single building.

如何建造闸门How to Build the Gate

这个关卡就是一个函数,模型生成的每个邮政编码都会先经过它,然后才会被系统中的其他部分看到。parse 会返回 Postcode 或错误值,因此类型检查器会要求你同时处理这两种情况。The gate is one function, and every postcode a model produces passes through it before anything else in your system sees it. parse returns a Postcode or an error value, so the type checker makes you handle both.

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,而这种格式不允许使用该编号。The first input is accepted and normalized. The second is the old six-digit code, and the gate names what is wrong with it. The third has a local government number of 00, which the format excludes.

在第四个输入中,字母 O 出现在只能填数字的位置,而 parse_lenient 可以判断用户可能想输入什么。校验关卡仍会拒绝该输入,并以提问的方式向用户提供建议。更正只是一种猜测,因此在用户确认之前,不应将其传入数据库或用于调用付费 API。In the fourth input, the letter O stands where only a digit can, and parse_lenient can tell what was probably meant. The gate still refuses the input and returns the suggestion as a question for the user. A correction is a guess, so it shouldn't reach a database or a paid API call until the user confirms it.

如何使用 NIPOST 确认代码How to Confirm a Code with NIPOST

该门禁可证明字符串格式正确。NIPOST 是否已将该邮政编码分配给某栋建筑,则是另一个问题,一次一级查询即可免费得出答案。运行前,请将密钥设置为环境变量 NG_POSTCODE_API_KEY。The gate proves that a string is well-formed. Whether NIPOST has assigned the code to a building is a separate question, and one level 1 lookup answers it at no cost. Set your key in the NG_POSTCODE_API_KEY environment variable before you run this.

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 采取行动之前,请先使用正式密钥。EK-01-A03-FK-01 is the example in NIPOST's documentation and belongs to no building, so the answer is False. A rejected key or a failed connection returns a string that begins with unconfirmed, which keeps a failed lookup apart from a code that NIPOST has not assigned. A NIPOST test key can report an assigned code as not assigned, so use a live key before you act on False.

如何按前缀路由工作How to Route Work by Prefix

由于每个后续片段都会进一步缩小前一个片段所指的范围,代码前缀就能标识一个区域:第一个片段表示州,前两个片段表示地方政府辖区,依此类推,直到建筑物,因此路由只需进行字典查找。Because each segment narrows the last, a prefix of the code names a region: the first segment is a state, the first two are a local government area, and so on down to the building, which makes routing a dictionary lookup.

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

路由表由你维护并可供审查。模型不参与这一步,同一代码总会到达同一个站点。遇到未知前缀时,会转到一个有名称的默认项,因此不会因为路由表不完整而丢失任务。The routing table is data you own and can review. The model has no part in this step, and the same code always reaches the same depot. An unknown prefix falls through to a named default, so a job is never lost because a table was incomplete.

如果让模型根据自由文本地址选择目的地,同一位客户可能在不同日期被送往不同的配送站,因此应在代码中固定这一选择。If you let a model choose the destination from a free-text address, the same customer can reach a different depot on a different day, so keep that choice in code.

如何将这些步骤组合起来How to Put the Steps Together

这三个步骤组合成一个函数。它接收模型提取出的字符串,并返回一个站点或下一步操作;它只会对已经解析过的输入调用 NIPOST。The three steps compose into one function. It takes the string the model extracted and returns either a depot or the next action, and it calls NIPOST only for input that has already parsed.

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

每个返回值都会说明接下来要做什么:向用户提问、重试,或前往某个仓库。模型只提供文本参数,不提供其他内容。若要在问题中加入建议的更正,请调用前文中的 gate,而不是 parse。Every return value names what happens next: a question for the user, a retry, or a depot. The model contributes the text argument and nothing else. To include a suggested correction in the question, call gate from the earlier section in place of parse.

如何为智能体提供带有强制限制的工具How to Give an Agent Tools with Enforced Limits

当智能体确实需要直接处理邮政编码时,应为它提供带有书面契约的工具,就像为微服务编写文档一样。默认情况下,ng-postcode-mcp 服务器通过模型上下文协议提供五种工具,并且可与任何 MCP 客户端配合使用。When an agent does need to work with postcodes directly, give it tools with a written contract, the way you would document a microservice. By default, the ng-postcode-mcp server exposes five tools over the Model Context Protocol, and it works with any MCP client.

A directory listing of the ng-postcode MCP server showing its five tools, each with a name and a description

从 MCP 客户端视角看到的五个默认工具,如 Glama 目录列表所示。The five default tools as an MCP client sees them, shown here in Glama's directory listing.

{
  "mcpServers": {
    "ng-postcode": {
      "command": "uvx",
      "args": ["ng-postcode-mcp"],
      "env": { "NG_POSTCODE_API_KEY": "nipost_live_..." }
    }
  }
}
Tool Network Needs a key Cost
validate_postcode None No Free
lookup_postcode NIPOST Yes Free, always level 1
autocomplete_postcode NIPOST Yes Free tier
find_postcode_at_location NIPOST Yes Free tier
resolve_address NIPOST and a geocoder Yes Free tier
lookup_postcode_details NIPOST Yes Credits on every call. Registered only when NG_POSTCODE_MAX_LEVEL is 2 or more

服务器强制执行三项限制,因此无论模型决定采取什么行动,这些限制都会生效:The server enforces three limits, so they hold whatever the model decides to do:

  1. 这五个默认工具均为只读工具。

    The five default tools are read-only.

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

    The key is read from the environment. It's never a tool argument, so the model never sees it and cannot repeat it.

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

    Paid lookups belong to a sixth tool, lookup_postcode_details, which the server registers only after you raise NG_POSTCODE_MAX_LEVEL to 2 or more.

Level 2 会返回房屋地址并消耗额度。在默认服务器上,模型没有可用于请求该信息的工具:lookup_postcode 只接受 Level 1,而调用付费工具会收到以下回复:Level 2 returns a house address and spends credits. On a default server the model has no tool that can request it: lookup_postcode accepts level 1 only, and a call to the paid tool gets this reply:

Unknown tool: lookup_postcode_details

提高上限后,付费工具会显示出来,但不再带有只读提示,因此即使客户端会自动批准只读工具,在它消耗积分之前仍会先征求你的同意。除非你提高 NG_POSTCODE_MAX_PAID_CALLS,否则服务器在单次运行中最多只会执行 25 次付费查询。超过上限的请求会收到以下回复:When you raise the cap, the paid tool appears without the read-only hint, so a client that approves read-only tools automatically still asks you before it spends credits. The server also stops after 25 paid lookups in one run unless you raise NG_POSTCODE_MAX_PAID_CALLS. A request above the cap gets this reply:

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.

该权限存在于由用户设置的配置中,任何提示都无法更改它。The permission lives in configuration that a person sets, and no prompt can change it.

如何处理每一种失败状态How to Handle Every Failure State

假设模型会生成错误代码、密钥会过期、网络会故障。下表列出了每种状态、检测该状态的组件,以及智能体收到的内容。每条消息都是服务器 0.6.2 版本的输出,记录于 2026 年 10 月 13 日。Assume the model will produce a wrong code, the key will expire, and the network will fail. The table below lists each state, the component that detects it, and what the agent receives. Each message is the output of version 0.6.2 of the server, recorded on 13 October 2026.

State Detected by What comes back
Old six-digit code Parser, offline expected 11 letters and digits, found 6
Segment out of range Parser, offline invalid lga segment
Look-alike typo sent to a lookup Server, before any network call '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.
Well formed, never assigned NIPOST "valid": false, "status": "not_found"
No key configured Server This tool needs a NIPOST API key
Key rejected NIPOST invalid_api_key (401), with where to create a new key
Level above 1 sent to lookup_postcode Server Input should be less than or equal to 1
Level above the server's cap Server The cap message shown above
Level the key lacks NIPOST level_not_granted (403)
API unreachable HTTP client Could not reach the NIPOST API: All connection attempts failed
A hand-drawn flowchart of the six checks the lookup_postcode tool makes, from top to bottom. The first three happen before any network call: whether the level is 1, whether a key is configured, and whether the code parses. The last three belong to one request to NIPOST: whether the API answered within the timeout, whether the key was accepted, and whether the reply decodes. A red arrow from each check points to the message the model receives when it fails. Passing all six gives a typed result that is either valid or not found.

这些检查按此顺序运行,前三项不会产生任何成本,因为它们在发送任何请求之前就已完成。The checks run in this order, and the first three cost nothing because they finish before any request is sent.

第三行是相似字符造成的拼写错误。服务器找到了一个可能的更正方式,但没有采用。它没有发起查询,并在回复中要求模型向用户确认。如果系统擅自更正代码并继续查询,就会返回一栋用户从未提及的建筑的真实地址。The third row is the look-alike typo. The server found a likely correction and declined to use it. It sent no lookup, and its reply tells the model to confirm with the user. A system that corrected the code and continued would return a real address for a building the user never named.

A hand-drawn sequence diagram with four participants: a user, a language model, the MCP server, and the NIPOST API. The user asks for a code that has the letter O in place of a zero. The model calls lookup_postcode, and the server refuses with a suggested correction and sends no request to NIPOST. The model asks the user to confirm, the user says yes, and only then does the corrected code go to the server, which calls NIPOST and receives not found.

输入错误的代码会在发出任何请求之前被拒绝,只有在用户确认后,修正后的代码才会发送至 NIPOST。A mistyped code is refused before any request is made, and the corrected code reaches NIPOST only after the user confirms it.

第四行是虚构出来的代码。模型可以生成一个符合格式所有规则、却不属于任何建筑的代码,而离线验证仍会接受它。只有注册表才能区分这两种情况,因此要在第 1 级查询所有重要代码。The fourth row is the hallucinated code. A model can produce a code that satisfies every rule of the format and belongs to no building, and offline validation accepts it. Only the registry can tell the two apart, so look up every code that matters at level 1.

HTTP 客户端通过三种方式保护每个请求。它为整个交互设置了十秒的截止时间,因此连接停滞时不会一直占用智能体的执行轮次。它不会跟随重定向,因为重定向会将你的密钥发送到另一台主机。出于同样的原因,它会拒绝使用普通的 http:// 基础 URL,但在你自己的机器上除外。The HTTP client protects each request in three ways. It puts a ten-second deadline on the whole exchange, so a stalled connection can't hold an agent's turn open. It doesn't follow redirects, because a redirect would carry your key to another host. It refuses a plain http:// base URL for the same reason, except on your own machine.

如何解读你不信任的回复How to Decode a Reply You Do Not Trust

同样的怀疑也适用于 API 的回复。直接读取响应体的智能体,总会想办法解读它收到的任何内容。这个库将工作拆分开来:lookup 将请求构建为纯数据,而 decode 则把状态和响应体转换为类型化结果或错误。无需联网即可对此进行测试。The same suspicion applies to the API's reply. An agent that reads a response body directly will find a way to interpret whatever it receives. The library separates the work: lookup builds a request as plain data, and decode turns a status and a body into a typed result or an error. You can test this with no network.

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)
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)都会通过同一份包含这类用例的共享文件中的测试。In the second reply, the body is valid JSON with a 200 status, and the valid field is missing. A loose decoder would read the missing field as false and report that the building doesn't exist. This one reports that the response was malformed, so a missing field never becomes a claim about a building. Every implementation of the library, in Rust, Python, JavaScript, and Java, passes the same shared file of cases like this one.

这无法解决的问题What This Does Not Solve

有效且已分配的邮政编码仍可能是错误的。如果用户提供的是邻居的邮政编码,本文中的所有检查都会通过。要确认邮政编码确实属于你面前的这个人,需要采用其他验证措施,例如向其展示该编码对应的地址。A valid, assigned postcode can still be the wrong one. If a user gives a neighbor's code, every check in this article passes. Confirming that a code belongs to the person in front of you needs a different control, such as showing them the address it resolves to.

仅凭文字通常无法确定一栋建筑:“Fabian Hotel 后面,NTA Road 旁”描述的是一个区域;当证据仅支持这一点时,resolve_address 工具会返回区域前缀,并向用户提问。要确定具体建筑,请索取位置图钉。Text alone rarely identifies a building: "Back of Fabian Hotel, off NTA Road" describes an area, and the resolve_address tool answers with an area prefix and a question for the user when that is all the evidence supports. For an exact building, ask for a location pin.

地址属于个人数据:二级查询会返回住宅地址,因此应按照《尼日利亚数据保护法》处理查询结果,并将上限保持在一级,除非确有更高权限需求。Addresses are personal data: level 2 lookups return a house address, so handle the result under the Nigeria Data Protection Act and keep the cap at level 1 unless you need more.

结论Conclusion

模型是整个流程中最不可靠的组件,因此应把最简单的任务交给它:提取字符串。解析器可以免费检查结构。注册表会确认该建筑是否存在。你自己的表格以地址前缀为键,决定工作分配到哪里。需要更正时,就向用户提问;而涉及费用或会暴露地址的权限,则放在由人控制的配置中。The model is the least reliable component in the path, so give it the smallest job: extracting a string. A parser checks the structure for free. The registry confirms that the building exists. Your own table, keyed by prefix, decides where the work goes. Corrections return to the user as questions, and permissions that cost money or reveal an address sit in configuration that a person controls.

即使模型学会了新格式,这一边界仍然有用。同样的设计也适用于地址系统、税号或账号的下一次变更,而模型也会在这些变更发生后很久才学会适应。The boundary stays useful after models learn the new format. The same design applies to the next change to an address system, a tax code, or an account number, which models will also learn late.

该代码在 github.com/Adeniyikayodee/ng-postcode 上以 MIT 许可证开源。安装适用于你的语言的库:The code is open source under the MIT license at github.com/Adeniyikayodee/ng-postcode. Install the library for your language:

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。For Java, the Maven coordinate is io.github.adeniyikayodee:ng-postcode-api. If the library gets a case wrong, please open an issue.

值得记下

信任边界:模型提供负载,虚线后的每一个决策都属于确定性代码。
代码的每个前缀都命名一个更小的区域,从州到单栋建筑。
如果你让模型从自由文本地址中选择目的地,同一个客户在不同日期可能会到达不同的网纽。