qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

三家店都叫“青禾茶铺”,助手该填哪个商户 ID?

做一个从店名到具体分店的离线查询助手:保留同名候选,逐步补足线索,再用明确的资源编号读取详情。

展开本文目录
  1. 下载完整练习包
  2. 显示名称方便人读,未必能当唯一键
  3. 编号要连同来源、类型和范围一起解释
  4. 先拿到候选集合,再决定下一步
  5. 追问应当缩小候选,而不是多说一句话
  6. 用户选中的编号,也必须属于眼前这份候选
  7. 完整跑一遍:三个候选如何变成一份详情
  8. 留下“为什么是这家”,也承认编号的寿命
  9. 交付时,把控制流程和理解能力分开验收
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了

用户问:“青禾茶铺几点关门?”目录中有东京车站店、东京大学店和京都车站店,显示名称都叫青禾茶铺。助手如果把搜索第一项的编号塞进查询工具,参数可能完全合法,查到的也是真实记录,却回答了另一家店。问题发生在工具调用之前:这句话究竟指向哪个对象?

本项目用四家虚构店铺构成完整本地目录,从名字查询、追问线索、选择候选,一直走到按规范编号读取详情。你会得到一份单文件应用及可核对的运行日志。它接续商户课程中“缺标识先澄清”的要求,具体练习怎样处理多个同名对象,而不是只在提示词里补一句“不要猜”。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • resolve_shop.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B resolve_shop.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 离线固定数据与应用规则,不代表真实模型、完整协议或服务联调。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

显示名称方便人读,未必能当唯一键

先看一个很短却很危险的写法:按 display_name 建字典。三条青禾茶铺记录会使用同一个键,后面的记录覆盖前面的记录。程序没有报错,四家店却只剩两个字典项。名字相同只说明标签相同,不能据此决定三条记录是同一个对象,更不能擅自丢掉其中两条。

Google AIP-148 把 display_name 作为可修改的人类可读字段,并建议不施加唯一性要求;AIP-122 另用 resource name 指定资源。这里的英文 name 很容易误导读者:规范里的资源名可能是一段有层级的标识字符串,不是招牌上写的店名。本例因此分别叫 display_name 和 resource。

两个不同的名字也可能指向同一条目录记录,例如店铺有简称或改过名。但这种关系需要来源明确提供。本地夹具为三家茶铺都登记了别名“青禾”,所以简称查询仍返回三家;它不是更精确的身份信息。把别名表写成一个名字只指向一条记录,会再次丢掉歧义。

编号要连同来源、类型和范围一起解释

程序使用 //directory.example/regions/jp/shops/t2 表示一个资源:directory.example 是虚构来源,regions/jp 限定父级范围,shops 表明对象类型,t2 才是这个集合内的短编号。只保存 t2,换一个来源或换成产品集合后,就未必还指向同一个东西。目录构造时检查完整形式,并拒绝重复的规范标识。

AIP-122 区分单个 API 内的资源名与跨 API 引用的完整资源名。我们借用带来源和层级的表示法,不要求所有商户服务照这个格式命名。真实工具可能有自己的商户 ID 与服务上下文;关键是保留那份契约所需的信息,不能把看起来像编号的字符串随意拼进另一套接口。

对调用方来说,编号通常应从可信目录结果中取回,再原样传给详情查询。本例不会把用户输入“青禾茶铺”哈希一下当作商户编号,也不会根据地址猜 t2。规范标识能指向已知目录对象;它本身并不证明用户想问这个对象,也不等于获得了任何操作授权。

先拿到候选集合,再决定下一步

start 按显示名或已登记别名做精确匹配,保留所有匹配记录。零条得到 not_found,多条得到 ambiguous,一条得到 resolved。这里的 resolved 只表示在本例完整固定目录和精确字段规则下唯一;真实搜索若只是返回排名靠前的一条,不能沿用这个结论。

本例只去除输入两端空白并对字母做大小写折叠,不纠正错别字,不做拼音相似度,也不把所有 Unicode 字符强行改成同一种形式。输入规则越宽,就越可能把不同名字合并为候选;这些转换应有明确目的,不能把字符串处理的结果当成现实对象相同的证明。

候选存在 View 中,每项仍保留规范标识、城市、分店和地址。详情读取要求当前 View 已经 resolved,模糊阶段不会先查一家再让模型圆回来。not_found 的意思也限定为“这些条件在当前目录中没有匹配”,不能扩写成“现实中没有这家店”。

追问应当缩小候选,而不是多说一句话

最初三家茶铺分布在东京与京都,所以先问城市。用户补充东京后,还剩车站店和大学店,下一问就改成分店。question 按 city、branch 的固定顺序,选第一个在当前候选中确有不同值的字段;它不声称问题数量全局最少,但不会对所有候选都一样的城市反复提问。

refine 只筛当前候选,保留字段值与补充信息匹配的记录,不会重新查全目录后偷偷放回已排除的对象。新信息若一条都没排除,就拒绝这次无效缩小;若全部排除,则发布空候选与 not_found。大阪不在这批候选里,程序不会为了给出答案自动退回之前的东京店。

这份小应用把 refine 定义为追加条件。若用户是在纠正之前给错的城市,应开启一次新的 start,再补正确信息;不能把“修改条件”伪装成继续收窄。如果支持字段仍不能区分候选,程序改为展示包含地址与编号的候选项。用户也无法辨认时,应继续寻找可靠线索,不要求其随便选一家。

用户选中的编号,也必须属于眼前这份候选

页面可以把每个候选的规范标识作为选项值,把店名、分店和地址作为可读标签。choose 先核对候选视图标记,再确认编号确实在当前 candidates 中。一个合法格式的咖啡店编号不在茶铺候选里,不能因为“这也是目录里的 ID”就通过。裸编号或另一个来源的相似编号同样不能代入。

每次 start、有效 refine 或 choose 都产生新视图,标记由本次解析器的会话编号与递增序号组成。旧按钮可能在用户改口后才被点击;即使那个旧编号碰巧仍在新列表中,旧视图标记也会使选择被拒绝。测试特意用全称查询后又用简称查询,两次候选完全相同,仍拒绝第一次的按钮。

视图标记负责把一个选择对应到它所看到的候选状态,不是身份凭证。它不实现登录或权限系统。代码还检查来自另一解析器会话的选择不能混入,并在失败时保持当前视图不变,使用户看到的列表与应用准备处理的列表继续一致。

完整跑一遍:三个候选如何变成一份详情

保存下面的完整代码,或解压本篇练习包后运行 resolve_shop.py。Python 3.10 或更新版本即可,无需安装依赖。先预测候选数量如何变化,再核对日志的三、二、一。成功后 details 使用已经确定的规范标识查字典,不会重新按店名搜索;断言检查结果确实是大学店,营业时间为 10:00-19:00。

同一个脚本接着演示旧视图选择、外来候选编号、无匹配城市、别名与改名等边界。第一段名字字典丢记录是故意保留的反例;真正的 Resolver 始终按完整资源标识建索引。全部输入在 main 内明确给出,模拟的是应用控制流程,不是声称语言模型已经理解了任何自然语言。

展开收起代码 · 小实验python · 204 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Offline name-to-resource workflow using fictional shops and fixed inputs."""
from dataclasses import dataclass, replace
import re
from uuid import uuid4


PREFIX = "//directory.example/regions/jp/shops/"
FIELDS = ("city", "branch")


def key(text):
    if not isinstance(text, str) or not text.strip():
        raise ValueError("a non-blank string is required")
    # Local matching policy only: strip outer spaces and fold letter case.
    return text.strip().casefold()


@dataclass(frozen=True)
class Shop:
    resource: str
    display_name: str
    city: str
    branch: str
    address: str
    hours: str
    aliases: tuple = ()


@dataclass(frozen=True)
class View:
    token: str
    label: str
    candidates: tuple
    clues: tuple
    status: str
    evidence: tuple


class StaleChoice(Exception):
    pass


class InvalidChoice(Exception):
    pass


class Resolver:
    def __init__(self, catalog):
        self.catalog = tuple(catalog)
        self.by_id = {}
        for shop in self.catalog:
            if not re.fullmatch(re.escape(PREFIX) + r"[a-z0-9-]+", shop.resource):
                raise ValueError("resource must include this source, region and shop type")
            if shop.resource in self.by_id:
                raise ValueError("duplicate canonical resource")
            self.by_id[shop.resource] = shop
        self.serial = 0
        self.session = uuid4().hex
        self.current = None
        self.detail_reads = []

    def publish(self, label, candidates, clues, evidence):
        self.serial += 1
        status = "not_found" if not candidates else "resolved" if len(candidates) == 1 else "ambiguous"
        self.current = View(f"{self.session}:{self.serial}", label, tuple(candidates), tuple(clues), status, tuple(evidence))
        return self.current

    def checked(self, token):
        if self.current is None or token != self.current.token:
            raise StaleChoice("this choice belongs to a different candidate view")
        return self.current

    def start(self, label):
        normalized = key(label)
        candidates = [shop for shop in self.catalog
                      if any(normalized == key(name) for name in (shop.display_name,) + shop.aliases)]
        return self.publish(label, candidates, (), ("exact display-name or declared-alias match",))

    def question(self, token):
        view = self.checked(token)
        if view.status != "ambiguous":
            return None
        # Ask the first understandable field that actually distinguishes rows.
        for field in FIELDS:
            values = tuple(dict.fromkeys(getattr(shop, field) for shop in view.candidates))
            if len({key(value) for value in values}) > 1:
                return {"view_token": token, "field": field, "values": values}
        return {"view_token": token, "field": "candidate", "values": tuple(
            (shop.resource, shop.display_name, shop.city, shop.branch, shop.address)
            for shop in view.candidates)}

    def refine(self, token, field, value):
        view = self.checked(token)
        if view.status != "ambiguous" or field not in FIELDS:
            raise InvalidChoice("refinement needs an ambiguous view and a supported field")
        normalized = key(value)
        candidates = tuple(shop for shop in view.candidates if key(getattr(shop, field)) == normalized)
        if len(candidates) == len(view.candidates):
            raise InvalidChoice("this information does not narrow the current candidates")
        return self.publish(view.label, candidates, view.clues + ((field, value),),
                            view.evidence + (f"filter {field}: {len(view.candidates)} -> {len(candidates)}",))

    def choose(self, token, resource):
        view = self.checked(token)
        matches = tuple(shop for shop in view.candidates if shop.resource == resource)
        if len(matches) != 1:
            raise InvalidChoice("resource is not one of the current candidates")
        return self.publish(view.label, matches, view.clues,
                            view.evidence + ("explicit choice from current candidate IDs",))

    def details(self, token):
        view = self.checked(token)
        if view.status != "resolved":
            raise InvalidChoice("resolve the shop before reading its details")
        resource = view.candidates[0].resource
        self.detail_reads.append(resource)
        shop = self.by_id[resource]  # Canonical lookup; never search again by display name.
        return {"resource": resource, "display_name": shop.display_name,
                "city": shop.city, "branch": shop.branch, "address": shop.address, "hours": shop.hours}


CATALOG = (
    Shop(PREFIX + "t1", "青禾茶铺", "东京", "车站店", "示例街 1 号", "09:00-18:00", ("青禾",)),
    Shop(PREFIX + "t2", "青禾茶铺", "东京", "大学店", "示例街 2 号", "10:00-19:00", ("青禾",)),
    Shop(PREFIX + "t3", "青禾茶铺", "京都", "车站店", "样本路 3 号", "11:00-18:00", ("青禾",)),
    Shop(PREFIX + "c1", "纸船咖啡", "东京", "公园店", "样本路 4 号", "08:00-17:00"),
)


def expect(exception, action):
    try:
        action()
    except exception:
        return
    raise AssertionError(f"expected {exception.__name__}")


def main():
    resolver = Resolver(CATALOG)
    # A name-keyed dict silently loses two distinct shops.
    bad_index = {shop.display_name: shop for shop in CATALOG}
    assert len(CATALOG) == 4 and len(bad_index) == 2
    print("name-keyed index: 4 resources become 2 entries")

    initial = resolver.start("青禾茶铺")
    assert initial.status == "ambiguous" and len(initial.candidates) == 3
    assert resolver.question(initial.token)["field"] == "city"
    expect(InvalidChoice, lambda: resolver.details(initial.token))
    assert resolver.detail_reads == []
    print("name lookup: ambiguous; candidates=3; next=city")

    city = resolver.refine(initial.token, "city", "东京")
    assert len(city.candidates) == 2 and resolver.question(city.token)["field"] == "branch"
    expect(InvalidChoice, lambda: resolver.refine(city.token, "city", "东京"))
    assert resolver.current == city
    print("city clue: ambiguous; candidates=2; next=branch")

    resolved = resolver.refine(city.token, "branch", "大学店")
    detail = resolver.details(resolved.token)
    assert resolved.status == "resolved" and detail["resource"] == PREFIX + "t2"
    assert detail["branch"] == "大学店" and detail["hours"] == "10:00-19:00"
    print("branch clue: resolved; resource=" + detail["resource"])
    print("detail lookup: branch and opening-hours assertions passed")

    old = resolver.start("青禾茶铺")
    current = resolver.start("青禾")  # Same candidate IDs, a new conversation state.
    assert old.candidates == current.candidates
    expect(StaleChoice, lambda: resolver.choose(old.token, PREFIX + "t1"))
    other_session = Resolver(CATALOG)
    other_view = other_session.start("青禾")
    expect(StaleChoice, lambda: resolver.choose(other_view.token, PREFIX + "t1"))
    assert resolver.current == current
    print("old view with still-matching ID: rejected as stale")
    expect(InvalidChoice, lambda: resolver.choose(current.token, PREFIX + "c1"))
    expect(InvalidChoice, lambda: resolver.choose(current.token, "t1"))
    expect(InvalidChoice, lambda: resolver.choose(current.token, "//other.example/regions/jp/shops/t1"))
    chosen = resolver.choose(current.token, PREFIX + "t3")
    assert resolver.details(chosen.token)["city"] == "京都"
    print("explicit current choice: resolved; resource=" + chosen.candidates[0].resource)

    initial = resolver.start("青禾茶铺")
    missing = resolver.refine(initial.token, "city", "大阪")
    assert missing.status == "not_found" and missing.candidates == ()
    expect(InvalidChoice, lambda: resolver.details(missing.token))
    expect(StaleChoice, lambda: resolver.choose(initial.token, PREFIX + "t1"))
    print("unmatched clue: not_found; no fallback to a previous shop")

    unknown = resolver.start("目录里没有的名字")
    assert unknown.status == "not_found" and resolver.question(unknown.token) is None
    alias = Resolver((replace(CATALOG[0], display_name="青禾新名", aliases=("青禾茶铺",)),))
    renamed = alias.start("青禾茶铺")
    assert alias.details(renamed.token)["resource"] == PREFIX + "t1"
    expect(ValueError, lambda: Resolver((CATALOG[0], CATALOG[0])))
    expect(ValueError, lambda: Resolver((replace(CATALOG[0], resource="t1"),)))
    expect(ValueError, lambda: resolver.start("   "))
    # If both supported fields are equal, do not ask a useless city/branch question.
    identical_labels = Resolver((CATALOG[0], replace(CATALOG[0], resource=PREFIX + "t9")))
    view = identical_labels.start("青禾茶铺")
    assert identical_labels.question(view.token)["field"] == "candidate"
    print("PASS: identity, strict narrowing, scoped IDs, stale-choice rejection")


if __name__ == "__main__":
    main()

怎样运行:保存为 resolve_shop.py,用 Python 3.10 或更新版本运行:python resolve_shop.py。仅标准库,数据全为虚构,不联网、不调用模型。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。离线固定数据与应用规则,不代表真实模型、完整协议或服务联调。

运行后对照结果
name-keyed index: 4 resources become 2 entries
name lookup: ambiguous; candidates=3; next=city
city clue: ambiguous; candidates=2; next=branch
branch clue: resolved; resource=//directory.example/regions/jp/shops/t2
detail lookup: branch and opening-hours assertions passed
old view with still-matching ID: rejected as stale
explicit current choice: resolved; resource=//directory.example/regions/jp/shops/t3
unmatched clue: not_found; no fallback to a previous shop
PASS: identity, strict narrowing, scoped IDs, stale-choice rejection

留下“为什么是这家”,也承认编号的寿命

View.evidence 记录名字或登记别名命中、城市筛选从三条变两条、分店筛选从两条变一条;clues 保存用户补充的原值。这样最终不只是一个 t2,还能回看它是怎样被确定的。若用户直接选中候选,证据则记录这次明确选择,不能假装那家店是系统凭常识推断出来的。

本例通过一个改名夹具说明:显示名改了,只要目录明确保留同一资源编号并登记旧名别名,仍可读到同一记录。这个结论来自夹具的身份政策,并非所有 ID 永远不变。Google Places 文档明确说明同一地点可能有多个 Place ID,标识也可能随时间变化;跨来源合并记录更需要来源规定或独立证据。

因此,获得规范标识之后仍应遵守来源的生命周期约定。本文不靠“编号永远有效”来证明解析成功,也不演示真实商户更名或地点合并。真实接入中若编号失效、来源返回多条映射,应把新的不确定性重新暴露出来,不能暗中换成一个名字近似的对象。

交付时,把控制流程和理解能力分开验收

这份应用覆盖固定目录、精确条件、候选选择和只读详情,不覆盖模型提取城市、网络检索、数据缺漏或真实商户真实性。目录和状态都只在单进程内存中;视图检查与更新按顺序执行,没有验证并发点击、数据库事务或跨进程恢复。若前端改为远端调用,状态绑定和原子更新还需单独实现。

请交出脚本、运行日志、三个同名对象的身份对照表,以及一次从模糊到唯一的候选变化记录。再标出哪些结论依赖本题的完整目录假设,哪些来自用户明确补充。这样读者既能看到应用完成了查询,也能知道它没有凭一个名称相等就替用户确定意图。

记住这三点

  • 显示名、别名和规范资源标识用途不同;同名记录不能按名字覆盖。
  • 补充信息应严格筛当前候选,不能在无匹配时暗中退回以前的对象。
  • 候选选择必须同时符合当前视图与当前候选编号,随后按确定的资源读取详情。

别把例子用过头

  • 把搜索第一条、最高分或唯一返回行误当成用户意图已经确定。
  • 只检查编号格式或目录存在,漏掉它是否属于这次候选及当前视图。
  • 把固定目录中的唯一匹配扩大为现实世界唯一,或假设所有来源的标识永远不变。

自己试一小步

如果同一城市有两家同名、同分店称呼的书店,你能让助手继续澄清,而不把两条目录记录合并吗?

  1. 独立设计五条虚构记录,包含重复显示名、共享简称、相同短编号但不同来源的情形。
  2. 选择一个有帮助的新字段扩展问题与筛选,再设计一条完全不缩小候选的补充信息。
  3. 保存旧候选选项,开启新查询后再提交它,核对状态没有被旧选择覆盖。

做到这些,就算完成

  • 是否保留每个不同资源,完整标识是否明确来源、类型与所属范围?
  • 每次接受的补充是否真正缩小候选,无匹配是否明确保留而不是自动改选?
  • 证据能否说明用户为何选中这条记录,并区分离线控制流程与真实模型、来源验证?

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • AIP-148:显示名与标准字段 (新标签页)↗Google API Improvement Proposals · 英文

    阅读 Other names 的 display_name 与 Resource names and IDs;支持显示名可变、面向人读且不要求唯一。其字段规范针对该 API 设计体系。

    2026-10-13 已打开原始公开正文并核对所列段落;无需登录。本文没有访问任何真实商户或调用远端 API。

  • AIP-122:资源名与引用范围 (新标签页)↗Google API Improvement Proposals · 英文

    阅读 Guidance、Resource ID segments、Full resource names、Fields representing resource names;支持资源标识的唯一范围、父级集合和跨 API 来源信息。

    2026-10-13 已打开原始公开正文并核对所列段落;无需登录。本文没有访问任何真实商户或调用远端 API。

  • Places API:地点标识与详情查询 (新标签页)↗Google Maps Platform · 英文

    阅读 Overview 与 Retrieve place details using the place ID;支持从查询结果取得编号再读详情,以及同一地点可能有多个编号、编号可能变化的边界。没有实际调用该服务。

    2026-10-13 已打开原始公开正文并核对所列段落;无需登录。本文没有访问任何真实商户或调用远端 API。