六步带你从零搭 Claude 电商 Agent(附源码解析)

从零搭 Claude 电商 Agent:后端实现与源码解析

关键词:Claude Commerce Agents、StorefrontBackend、护栏 gates、商品变体建模、grounding、SKILL.md、Agent 工程


目录

  • 一、先跑起来:五分钟看到它长什么样
  • 二、仓库结构:七个包是怎么切的
  • [三、StorefrontBackend 凭什么是 11 个必填加 3 个可选?](#三、StorefrontBackend 凭什么是 11 个必填加 3 个可选? "#%E4%B8%89storefrontbackend-%E5%87%AD%E4%BB%80%E4%B9%88%E6%98%AF-11-%E4%B8%AA%E5%BF%85%E5%A1%AB%E5%8A%A0-3-%E4%B8%AA%E5%8F%AF%E9%80%89")
  • [四、两个异常:NotOffered 和 Unavailable](#四、两个异常:NotOffered 和 Unavailable "#%E5%9B%9B%E4%B8%A4%E4%B8%AA%E5%BC%82%E5%B8%B8notoffered-%E5%92%8C-unavailable")
  • [五、商品为什么分 Plain、Family、Variant 三形态?](#五、商品为什么分 Plain、Family、Variant 三形态? "#%E4%BA%94%E5%95%86%E5%93%81%E4%B8%BA%E4%BB%80%E4%B9%88%E5%88%86-plainfamilyvariant-%E4%B8%89%E5%BD%A2%E6%80%81")
  • [六、从零实现自己的 Backend:六步](#六、从零实现自己的 Backend:六步 "#%E5%85%AD%E4%BB%8E%E9%9B%B6%E5%AE%9E%E7%8E%B0%E8%87%AA%E5%B7%B1%E7%9A%84-backend%E5%85%AD%E6%AD%A5")
  • [七、配置:enable_ 开关与 grounding gate](#七、配置:enable_ 开关与 grounding gate "#%E4%B8%83%E9%85%8D%E7%BD%AEenable-%E5%BC%80%E5%85%B3%E4%B8%8E-grounding-gate")
  • 八、一次加购凭什么要过四道卡?
  • [九、技能怎么写:拆一份真实的 SKILL.md](#九、技能怎么写:拆一份真实的 SKILL.md "#%E4%B9%9D%E6%8A%80%E8%83%BD%E6%80%8E%E4%B9%88%E5%86%99%E6%8B%86%E4%B8%80%E4%BB%BD%E7%9C%9F%E5%AE%9E%E7%9A%84-skillmd")
  • [十、这套东西在 AI 大模型开发里的位置](#十、这套东西在 AI 大模型开发里的位置 "#%E5%8D%81%E8%BF%99%E5%A5%97%E4%B8%9C%E8%A5%BF%E5%9C%A8-ai-%E5%A4%A7%E6%A8%A1%E5%9E%8B%E5%BC%80%E5%8F%91%E9%87%8C%E7%9A%84%E4%BD%8D%E7%BD%AE")
  • 给你的落点

一、先跑起来:五分钟看到它长什么样

Anthropic 把这个项目放在 github.com/anthropics/commerce-agents,Apache 2.0,Copyright 2026 Anthropic PBC。仓库 README 第一行就写明了它的性质:参考实现,不维护,不接受贡献

先说清楚它能做什么、不能做什么,避免带着错误预期往下读:

  • :搜索、比较、规划、组装购物车、回答订单和政策问题、记住顾客说过的话;后台侧做经营分析、目录维护、库存与定价建议。
  • 不能 :下单、收款、改线上商品。仓库里所有公司品牌人物都叫 ACME,checkout 只渲染购物车,每一次商家侧写入都要人批准

前置要求只有两样:Python 3.11+、Node 22,外加一个 ANTHROPIC_API_KEY

bash 复制代码
git clone https://github.com/anthropics/commerce-agents.git && cd commerce-agents
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt       # 七个包及固定依赖
cp .env.example .env                  # 填 ANTHROPIC_API_KEY
(cd examples && npm ci)               # 八个 web 应用共用一个 workspace
python scripts/run_demo.py retail     # API :8000 + 店铺前端 :3000

--merchant 起后台门户,--all 两个都起。四个垂直行业的端口是错开的:

垂直行业 店铺前端 后台门户
retail :3000 :3100
travel :3001 :3101
telecom :3002 :3102
entertainment :3003 :3103

每个 example 的 README 里都有一节 Try,列出 scripts/smoke_chat.py 会跑的对话,以及单条 prompt 配"好的回答应该是什么样"。建议先照着 travel 跑一遍------它的日期绑定库存和价格计算最能体现这套框架处理"询价型商品"的能力。

跑通之后再看代码,会比直接读源码清楚得多。


二、仓库结构:七个包是怎么切的

顶层目录一共十个:.claude-plugin.github/workflowscommerce-commondocsexamplesmerchant-agentplugins/commerce-builderscriptsshopping-agenttests

七个 pip 包的真实划分如下(注意包名和 import 名不一致,这是个容易踩的坑):

目录 pip 包名 import 名
commerce-common/ commerce-common commerce_common
shopping-agent/core/ shopping-agent-core shopping_agent
shopping-agent/runtime-messages-api/ shopping-agent-runtime shopping_agent_runtime
shopping-agent/runtime-agent-sdk/ shopping-agent-sdk shopping_agent_sdk
merchant-agent/core/ merchant-agent-core merchant_agent
merchant-agent/runtime-messages-api/ merchant-agent-runtime merchant_agent_runtime
merchant-agent/runtime-agent-sdk/ merchant-agent-sdk merchant_agent_sdk

managed-agents/ 两个目录不是 pip 包,装的是 manifest 和 MCP server。

这里有个值得注意的设计:包名带连字符、import 名带下划线,而且这些名字没有注册到公共 PyPI 索引上 。仓库的 pin 文件是从目录本地安装的,CI 里有一项检查就是确认这些包名在公共索引上保持未注册状态。这么做大概是防止有人 pip install shopping-agent-core 装到同名但无关的第三方包。

三个 runtime 包对应三种把 Agent 接进你自己系统的路径------这一层决定了你要在哪写对话循环、在哪接工具:

图四:三条运行路径怎么选

(图四:三种接法在"谁管对话循环"上的差别)

  • Messages API runtimeshopping_agent_runtime):最轻,对话循环你自己写,框架只给你执行工具和护栏。
  • Agent SDK runtimeshopping_agent_sdk):SDK 托管对话循环,你只负责接 backend 和工具。
  • Managed Agentsmanaged-agents/ 下的 manifest + MCP server):交给托管运行时,连循环都不用自己写。

商家侧(merchant_agent_runtime / merchant_agent_sdk)是同一套结构。起步用 Messages API 最直观,能看清每一步发生了什么。

commerce-common 是两边共享的部分,包含 config、fencing、memory、skills、grounding、presentation、executor frame、events 八个模块。

shopping-agent/core 为例,模块职责是这样的:

模块 装的是什么
types.py Product(含 options / variants / option_values / variant_of)、cart、orders、policies、disclosures、SessionContext、SessionState
backend.py StorefrontBackend 接口,以及 NotOffered / Unavailable
config.py ShoppingAgentConfig:能力开关、购物车上限、grounding 词表
prompt.py build_static_system(可缓存)与 build_dynamic_context(每请求,fenced)
tools/registry.py 工具契约,固定顺序
tools/presentation.py 内置展示工具的 payload schema
enrichment.py 组件与 session 记录的拼装,流式时是 partial payload
gates.py 购物车溯源、options 拦截、数量上限、per-session 写锁
grounding.py 政策 / 订单 / 目录的 grounding 规则
fencing.pymemory.py storefront_data 围栏与记忆提取 prompt
executor.py ShoppingToolExecutor,可子类化后作为 executor_class 传入

prompt.py 拆成 static 和 dynamic 两段这件事,是 prompt cache 能跑到高命中率的前提 ------静态系统提示词整段缓存,只有每请求变化的动态块走 fencing 后追加。README 里给出的验证方式是读 turn_completecache_read_input_tokens,或者看每次模型调用在 runtime logger 上打的那行;第二轮如果是 0,说明前缀变了,缓存没生效。


三、StorefrontBackend 凭什么是 11 个必填加 3 个可选?

这是整个项目里唯一需要你实现的接口,也是最该读透的文件。

先纠正一个流传较广的说法:网上不少介绍写的是"14 个方法的接口"。看源码的实际定义是------11 个 @abstractmethod(必须实现)+ 3 个带默认实现的普通方法(可选覆盖),加起来 14 个方法定义,但只有 11 个是必需的。

图二:Backend 方法与商品三形态

(图二:左为 11 必需 + 3 可选,右为商品三形态与加购规则)

按源码里的分区注释,11 个必需方法是:

python 复制代码
# -- Catalog --
async def search_products(self, session, query: str,
                          filters: SearchFilters | None = None,
                          limit: int = 8) -> list[Product]: ...
async def get_product_details(self, session, product_id: str) -> ProductDetails | None: ...

# -- Cart(唯一写操作)--
async def get_cart(self, session) -> Cart: ...
async def add_to_cart(self, session, product_id: str, quantity: int) -> Cart: ...
async def update_cart_item(self, session, product_id: str, quantity: int) -> Cart: ...
async def remove_from_cart(self, session, product_id: str) -> Cart: ...

# -- Customer context --
async def get_preferences(self, session) -> UserPreferences: ...

# -- Orders and policies --
async def get_orders(self, session, limit: int = 5) -> list[Order]: ...
async def get_order(self, session, order_id: str) -> Order | None: ...
async def search_policies(self, session, query: str) -> list[Policy]: ...

# -- Fulfillment --
async def get_fulfillment_options(self, session,
                                  product_ids: list[str]) -> list[FulfillmentOption]: ...

三个可选方法都有默认实现,不覆盖也跑得起来:

python 复制代码
async def checkout_handoff(self, session, cart) -> list[CheckoutHandoff]:
    return []          # 默认:host 的卡片链到自己的结算路由

async def get_account_context(self, session) -> dict[str, Any] | None:
    return None        # 默认:没有账户模型

async def get_disclosure(self, session, product_id) -> Disclosure | None:
    return None        # 默认:无披露信息(仅 enable_disclosures 时存在)

类 docstring 里有三句话我认为是这个文件最值钱的部分:

第一句 :每个方法都为 session 里的顾客行事,用 host 为该会话持有的凭证在服务端调用它自己的 API;模型看到的是方法的结果,永远看不到 token。
第二句 :购物车方法是唯一的写操作;每个写操作到达前都过了 executor 的溯源门和数量上限,但后端仍然要以原子方式执行自己的业务规则(资格、库存、限额),因为 executor 的锁只覆盖单个进程的会话。
第三句 :没有任何方法下单或转钱------checkout 渲染购物车,由 host 完成。

第二句尤其容易被忽略。很多人会以为"框架已经有护栏了,后端就不用再校验"------恰恰相反,护栏是进程内的,后端必须自己保证原子性和业务规则。多实例部署时这个差别会直接决定会不会超卖。

checkout_handoff 的实现方式

这个方法的 docstring 把上一篇文章里我讲的"模型看不到 URL"落到了具体实现上:

executor 在模型的调用之后 ,把结果放到 checkout 卡片的 payload 上,所以 URL 从来不是工具参数,也到不了模型那里。

注意"在模型的调用之后"这个时序------URL 是在模型已经决定"我要调 checkout 了"之后才被附加上去的,模型没有任何一个环节能接触或影响它。默认实现返回空列表,此时 host 的卡片链到自己的结算路由。

三种交接方式:

你的情况 卡片做什么 你要实现什么
结算是自己 app 里的路由 链到那个路由 什么都不用做,走默认
平台托管结算(购物车 API 无法在服务端收款) 打开平台的托管结算 URL checkout_handoff 返回该购物车的 URL
marketplace,每个卖家单独结算 一个卖家一个链接 每个 seller 返回一条 entry

四、两个异常:NotOffered 和 Unavailable

源码里定义了两个异常类,注释都写明了它们是信号(signal),不是失败(failure)。这两个的区分很讲究:

python 复制代码
class NotOffered(Exception):  # a signal the executor relays, not a failure
    """后端方法针对"本店不为该商品或上下文提供"的东西抛出
    (比如属于另一个卖家的配送选项),
    区别于"系统挂了"或"还没接上"。
    executor 告诉模型"本店不提供";
    整类系统缺失则在 config 里关掉开关。"""


class Unavailable(Exception):  # relayed like NotOffered, with its own wording
    """由 add_to_cart 针对"存在但现在买不了"的商品或变体抛出(缺货、此context不售)。
    消息里只给 id:什么买不了,以及(对变体)哪些兄弟变体有货。
    executor 转述,不写入任何东西。"""

两个细节值得单独拎出来:

第一,"缺货"和"不提供"是两种完全不同的事。 NotOffered 是能力边界(这个商店压根不卖这个),Unavailable 是瞬时状态(在目录里但现在买不了)。对用户的话术、对模型的下一步引导都不一样。

第二,Unavailable 的消息只许带 id。 源码注释写得很明确:"The message names ids only: what is unavailable and, for a variant, which sibling variants are in stock."

为什么这么限制?因为商品标题属于目录文本,而目录文本必须待在围栏(fence)里。如果让异常消息带上标题,就等于给了一条绕过 fencing 往模型上下文里塞目录文本的通道。这个约束看起来很细,但它是防止提示注入的一条实线。

系统缺失 vs 系统没接好

文档里还区分了另外两种状态,这个区分我认为是这套配置设计里最聪明的一点:

  • 整个系统你压根没有 (比如推荐页没有购物车)→ 把 enable_cart 关掉,相关的工具、提示词行、grounding 规则在所有路径上一起移除
  • 系统有,但还没接好 → 开关保持开启,让对应的 backend 方法抛异常,工具回答"当前不可用"。

区别在哪?关掉开关会改写 prompt 的字节 (工具被从 build_tools 里剔除);保持开启则 prompt 不变,只是运行时报错。所以"暂时没接好"的情况下不要关开关,否则接好之后 prompt 结构变了,缓存前缀也跟着变。


五、商品为什么分 Plain、Family、Variant 三形态?

这是我认为接真实电商系统时最容易做错、也最耗时 的一环。文档 docs/backends.md 用了一整节讲它。

三种形态:

形态 怎么识别 Agent 拿它做什么
Plain 没有 options 搜索返回它;购物车、改价、补货直接用它的 id
Family options,如 size: S/M/L 搜索返回它;详情列出 variants。加购、改价、补货必须用变体 id;暂停、促销、内容编辑可以用家族 id
Variant option_values(每个 option 一个值)和 variant_of(家族 id) 在家族详情里返回,带自己的 id、价格、库存;写入只认这一层

关键规则:变体 id 走的就是商品 id 那个字段,没有单独的 variant-id 字段 。而且家族和变体共用同一个命名空间------如果你平台上父级 id 可能等于子级 id,要在你的 backend 里给家族 id 加前缀

一个真实示例(文档里给的):

text 复制代码
你的平台                                     这里的记录
product  P-88  "Trail Tee"                  {"product_id": "P-88", "title": "Trail Tee",
  option  size: S M L                        "price": 24.0,
  variant V-1  S  24.00  12 in stock        "options": {"size": ["S", "M", "L"]}}
  variant V-2  M  24.00   0 in stock        details.variants[0] =
  variant V-3  L  26.00   4 in stock          {"product_id": "V-1", "price": 24.0,
                                               "in_stock": true,
                                               "option_values": {"size": "S"},
                                               "variant_of": "P-88"}  ... 以及 V-2、V-3

家族自己的数字怎么定

这一条很实用,而且前后台还不一样:

  • 店铺侧价格 = 最低的在售变体的价格("起"价);只要任一变体有货,家族就算有货。
  • 后台侧价格 = 最低变体的价格,不管有没有货;库存取总和。

为什么后台不看库存?文档给的理由是:这样某个尺码卖断时,目录里不会显示价格波动。 后台关心的是"这个商品定价多少",不是"现在还能不能买"。

12,000 字符这个上限要知道

详情接口会把一个家族的所有变体塞进一个 fenced 结果里返回 ,上限是 max_fenced_chars,默认 12,000 字符。

紧凑行大约 70~120 字符,所以一个家族大约能放 60 个变体。如果你的变体矩阵更大(比如一双鞋 8 个颜色 × 14 个尺码),文档建议拆成"每个主选项一个家族"------变成 8 个家族,每个 14 个变体。

⚠️ 超出上限会被直接截断,而且不报错。 所以上线前务必拿你最大的那个家族实测一下,否则会遇到"变体莫名其妙少了一半"且没有任何错误提示的情况。

什么不算变体

文档列了五类,都是真实电商里容易误判的:

  1. 按请求计算的价格或可用性(按搜索日期算的房价、按人数算的票价、座位)→ 请求维度走搜索 filter,返回的是该上下文的报价。
  2. 差异超过 option 值的兄弟记录(票档各有自己的区域和手续费、套餐各有自己的额度)→ 保持独立记录,用属性分组。
  3. 一个商品被多个卖家以不同价格卖 → 一条记录只有一个价格,要么返回你会卖的那个 offer,要么每个 offer 一条记录。
  4. 定制商品、捆绑包、带 modifier 的菜单项 → 选择项走请求属性或你自己的购物车扩展,由 backend 定价。
  5. 按重量计价的商品 → 数量按整单位计,所以按固定包装卖,包装本身可以是变体。

六、从零实现自己的 Backend:六步

图一:从零搭建六步

(图一:从 clone 到接上自己系统的完整路径)

第一步:定身份,绑凭证

在会话开始时绑定身份。 host 认证调用方,然后用解析出来的 principal 起一个会话:购物 Agent 是 customer id,商家 Agent 是 merchant id + operator。每个 backend 方法都收到这个 session 对象,从中读身份。

源码注释里有一句硬约束值得记住:没有任何路由和工具参数会携带 user id。 这一点比看起来重要------如果 user id 能作为工具参数传进来,模型就有机会传别人的 id。

凭证放在身份旁边,不放在模型旁边:

  • 按顾客区分的 token → 放在 session context 的子类上,或者放在一个 host 在登录时填充、backend 按 customer id 读取的 store 里。
  • 服务级凭证 → 传给 backend 的构造函数。

访客也是一个 principal。 文档要求把访客会话标记成 guest;当读操作需要账户(订单历史、保存的地址)时,抛一个异常,让你的 executor 子类把它转成"请顾客登录"。顾客登录后开一个新会话,不要复用。

第二步:多步流程保序

有些流程天然有顺序(先验身份再查资格再提交;先锁座再确认)。这个顺序由 backend 强制,不靠模型自觉

  1. 流程状态存在 backend 里,按 session 索引;
  2. 依赖步骤之前到达的调用,抛你自己的异常类;
  3. 在 executor 子类里把那个异常类映射到 domain_error,让工具结果说出"缺哪一步",而不是读起来像系统故障;
  4. 对你的平台会去重的写操作,用 session id + 购物车行 hash 派生幂等键
  5. 顾客在对话之外完成某一步(支付页、验证码)时,让 host 在 session 上排一个 app event,下一轮读取它。

第 5 条是个很实用的模式------对话和 web 流程之间的桥接,靠的就是这个 event 队列。

第三步:定结算方式

前面 checkout_handoff 那节已经讲过三种交接。要点是:URL 由 executor 在模型调用之后补到卡片 payload 上 ,示例卡片只链 https URL。支付完成后排一个 app event,让下一轮知道。

第四步:建模商品

就是上一节的三形态,这里不重复。

第五步:商家侧写入怎么落到家族

写操作 用哪个 id 传家族 id 会怎样
改价 变体 被 executor 拦下并指向变体 id;"全尺码涨 5%" 会拆成每个变体一条
补货 变体 同上
暂停 / 激活 家族或变体都行 把该家族所有变体一起下架或上架
促销 两者都行 展开成每个变体一行,各有自己的前后价,每行都计入单次变更条目上限
内容编辑 两者都行 编辑共享内容;变体共享家族内容字段时,backend 应拒绝对变体改这些字段

⚠️ 一个坑:如果你的领域用别的字段名表示价格 (房价的 nightly rate、票价的 fare),必须加到商家 config 的 price_bearing_fields 里,否则价格上限检查不会生效

第六步:平台给不了的数字,返回 None

这条原则我很认同,而且它是防止模型编数据的关键:永远不要返回 0 占位,返回 None 并附一句简短说明。

场景 返回什么
经营快照 拿不到的流量、转化率、客单价返回 None,note 说明是哪项
指标序列 空序列 + 说明原因
营销活动 渠道不报的花费或收入返回 None
定价上下文 min_price_basis:说明价格下限是商品成本还是店铺规则
库存告警、订单异常 只返回你能从库存和订单算出来的那几类
商家上下文 limitations 列表列全市级缺口(来源 + 一句话)

文档里给的例子:retail 的商家示例返回两条 limitations------订单历史只有 90 天邮件渠道不报收入数据

为什么非要返回 None 而不是 0?因为模型拿到 0 会当真,然后说出"您的收入是 0 元"这种话;拿到 None 加一句 note,它才能如实告诉用户"这个数据我这里没有"。


七、配置:enable_ 开关与 grounding gate

ShoppingAgentConfig 继承 BaseAgentConfig,真实定义里这几组字段最值得看:

python 复制代码
class ShoppingAgentConfig(BaseAgentConfig):
    assistant_name: str = "the shopping assistant"
    brand_voice: str = "warm, concise, and plain about trade-offs"
    model: str = "claude-sonnet-5"
    thinking_effort: ThinkingEffort | None = "low"

    domain_search_notes: str = ""
    enable_disclosures: bool = False

    enable_cart: bool = True
    enable_orders: bool = True
    enable_policies: bool = True
    enable_fulfillment: bool = True

    max_quantity_per_item: int = Field(default=24, ge=1)
    max_cart_lines: int = Field(default=100, ge=1)

absent_tools():开关是怎么生效的

python 复制代码
def absent_tools(self) -> frozenset[str]:
    """Names `build_tools` leaves out for the systems switched off above."""
    names: set[str] = set()
    if not self.enable_cart:
        names |= {"get_cart", "add_to_cart", "update_cart_item",
                  "remove_from_cart", "checkout"}
    if not self.enable_orders:
        names |= {"get_orders", "get_order_status", "present_order_status"}
    if not self.enable_policies:
        names.add("search_policies")
    if not self.enable_fulfillment:
        names.add("get_fulfillment_options")
    return frozenset(names)

关掉一个开关,工具、提示词行、grounding 规则会在所有路径上一起消失。这比我见过的多数做法干净------很多框架是"工具还在,但在提示词里写一句'你不能用 X'"。

grounding gate:一个我此前没见过的设计

这是 config.py 里信息量最大的一段。三个 gate 各有词表:

python 复制代码
policy_grounding_gate: bool = True
policy_intent_terms: tuple[str, ...] = (
    "return", "returns", "refund", "refunds", "exchange", "exchanges",
    "warranty", "guarantee", "cancel", "cancellation", "restocking",
    "fee", "fees", "shipping cost", "shipping costs", "delivery cost",
    "price match", "price lock", "membership", "subscription",
    "contract", "policy", "policies", "terms",
)
policy_intent_cues: tuple[str, ...] = (
    "?", "how", "what", "when", "can i", "could i", "do you", "does",
    "is there", "tell me", "explain", "how long", "how much",
)

order_grounding_gate: bool = True
order_intent_terms: tuple[str, ...] = (...)   # order, delivery, package, tracking ...
order_intent_cues: tuple[str, ...] = (...)    # ?, where, when, status, cancel, late ...

catalog_grounding_gate: bool = True
product_id_patterns: tuple[str, ...] = (
    r"\b[A-Z]{2,4}-\d{3,4}\b",
    r"\b[A-Z]{2,4}-[A-Z]{2,6}-\d{2,4}(?:-[A-Z0-9]{2,6})?\b",
)

机制是:当消息命中词表时,在这一轮对话的第一次迭代强制做一次读操作。

这是防幻觉的一道实卡------用户问"这个能退吗",模型不能凭训练知识回答,必须先 search_policies 拿到真实条款。

源码里有两条注释解释了细节设计,读起来很见功力:

id 正则最多匹配四位数字,所以五位数的订单号会走订单 grounding 而不是商品 grounding。
"delivered" 不在订单词表里,因为它会出现在普通的购物表述中("什么时候能 delivered" vs "我的包裹 delivered 了吗")。

第二条尤其微妙------词表设计要考虑假阳性,不能只看词本身是不是相关。


八、一次加购凭什么要过四道卡?

gates.py 是这套框架安全性的落点。文件开头的 docstring 把它做的事说全了:

一次购物车写入只接受本次会话里目录或订单工具返回过的商品 id (或已在购物车里的行);对还有 options 没选的商品,拦截加购并指向它的变体;把结果行的数量卡在配置上限内,并报告它施加了哪个上限;同一会话的写操作要串行,因为一轮里的工具调用是并发跑的。

图三:护栏四道卡

(图三:被拦下返回的是 held,不是 error)

第一卡:provenance(溯源)

python 复制代码
def check_provenance(state: ShoppingSessionState, product_id: str) -> ToolOutcome | None:
    """The held outcome when ``product_id`` has no session provenance, else None."""
    if product_id in state.seen_products:
        return None
    return ToolOutcome.held(PROVENANCE_GATE, provenance_error(product_id))

模型不能凭空编一个 id 加进购物车------这个 id 必须在本会话里被目录或订单工具返回过。

provenance_error 的提示文案也很有讲究,源码注释解释了为什么把 get_product_details 放在提示的第一位:

文本搜索匹配不了 id,而一次空搜索会被模型读成"这个商品不存在"的证据。

也就是说,如果提示模型"去搜索一下",它搜不到就会误判商品不存在,然后告诉用户"没有这个商品"。所以提示必须直接指到 get_product_details 这条能按 id 精确解析的路径上。

订单里的商品也算溯源,所以"再次购买"不需要重新搜索:

python 复制代码
def remember_order_items(state: ShoppingSessionState, orders: Sequence[Order]) -> None:
    """Items on the customer's own orders count as provenance,
       so a reorder needs no search."""

第二卡:options(家族拦截)

python 复制代码
def check_options(state: ShoppingSessionState, product_id: str) -> ToolOutcome | None:
    product = state.seen_products.get(product_id)
    if product is None or not product.has_options:
        return None
    return ToolOutcome.held(OPTIONS_GATE, options_error(product))

带 options 的家族不能直接加购。提示里会告诉模型:从顾客说的或 profile 里把每个 option 定下来,还有没定的就只问一次(用 chips 给出候选值),然后加对应变体的 id。

这里有个细节:options_error 里对 option 名做了 sanitize 并限制 60 字符------

python 复制代码
names = STOREFRONT_FENCE.sanitize_text(", ".join(product.options), max_chars=60)

因为 option 名是来自围栏之外的目录文本 ,必须走一遍 sanitize。和前面 Unavailable 只带 id 是同一条原则。

第三卡:per-session 写锁

python 复制代码
# The gates read the cart, compute, then write; a second mutation for the same session
# in the same gather must not interleave with that. Locks live only while held.
_cart_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()

def _cart_lock(session: ShoppingSessionContext) -> asyncio.Lock:
    lock = _cart_locks.get(session.session_id)
    if lock is None:
        lock = _cart_locks[session.session_id] = asyncio.Lock()
    return lock

WeakValueDictionary 是因为注释里那句"Locks live only while held"------锁只在被持有时存在,持有结束就可以被回收,不需要手动清理。

⚠️ 再次强调后端那句注释:这个锁只覆盖单个进程的会话。多实例部署时,两个进程可能同时改同一个购物车,所以后端必须自己做原子性。

第四卡:数量与行数上限

gated_add_to_cart 的主干逻辑:

python 复制代码
if held := check_provenance(state, product_id) or check_options(state, product_id):
    return held
requested = max(1, quantity)
max_quantity = config.max_quantity_per_item
async with _cart_lock(session):
    current = await backend.get_cart(session)
    existing = next((i for i in current.items if i.product_id == product_id), None)
    if existing is None and len(current.items) >= config.max_cart_lines:
        return ToolOutcome.error("The cart is full.")
    allowed = min(requested, max(0, max_quantity - (existing.quantity if existing else 0)))
    if allowed <= 0:
        return ToolOutcome.error(f"This item is already at the per-item limit of {max_quantity}.")
    cart = await backend.add_to_cart(session, product_id, allowed)
# The confirmation names the id only: titles are catalog text and stay inside fences.
capped = f" (capped at the per-item limit of {max_quantity})" if allowed < requested else ""
return _written(f"Added {product_id} x{allowed}{capped}. Cart now has {cart_summary(cart)}.", cart)

三个点:

  • 读-算-写全在锁内 ,先 get_cart 再算 allowed 再 add_to_cart
  • 超限不是报错,是截断allowed = min(requested, ...),然后在确认消息里明确说"(已按单品上限 24 截断)";
  • 确认消息只说 id,不说标题------源码注释:"titles are catalog text and stay inside fences"。

最值得学的一点:held ≠ error

四道卡里前两道返回的都是 ToolOutcome.held(gate_name, message)不是 error

这个区别很重要:held 的意思是"先别做,但你可以补一步再重试",并且会把补哪一步写清楚。模型读到提示后自己调 get_product_details 或选定变体,就能继续。相比之下直接 error 会中断任务流。

把"拦下"设计成可恢复的指引,而不是终止信号,这是这套护栏和多数"if 不合法就抛异常"做法的本质差别。


九、技能怎么写:拆一份真实的 SKILL.md

shopping-agent/skills/search-discovery/SKILL.md 的真实内容,比任何"技能怎么写"的教程都有说服力。

frontmatter 只有两个字段,但 description 承担了路由职责:

yaml 复制代码
---
name: search-discovery
description: Turning a described need (several constraints, a gift, a choice between
  candidates already in view, a search that came back empty or sold out) into a shortlist
  and a pick. Not needed when one search for the thing the customer named answers the
  request, or when the customer wants to learn what matters in a category first
  (purchase-research).
---

注意它既写了什么时候用,也写了什么时候不用 ,而且明确指向另一个技能(purchase-research)。这是多技能路由的关键------不写边界,模型就会在技能之间乱跳。

正文里几条我认为最值得抄的规则:

text 复制代码
- Take the budget, the recipient, dates, sizes, intended use, and dealbreakers out of
  the message and apply them; let the results show that you did instead of reading
  them back.

"用结果证明你听懂了,而不是复述一遍约束" ------ 这条直接决定了对话体验:复述约束的助手很啰嗦,直接用筛选结果说话的助手才像回事。

text 复制代码
- Search by default; a budget, a size, or a recipient you were not given narrows the
  shortlist and is asked about beside the results. Ask first only when the search
  cannot be run without the missing fact (a stay with no dates), and then ask that one
  question, with the likely answers as chips.

默认先搜,不要先问。 只有"缺了这个事实搜索根本跑不了"时才问,而且只问一个问题,并给出 chips 候选。这和多轮对话设计的常识相反,但对电商是对的------用户更希望看到结果,而不是回答问卷。

text 复制代码
- Run one search per distinct thing the request names, all in the same round.

同一个 round 里并行发多个搜索,不要一个个串着来。这是延迟优化,也和前面"单 Agent 拥有整段对话"的设计一致。

text 复制代码
- Before saying that several options fit under a figure, add up their prices. When the
  sum is over, give the sum, and offer no chip for a bundle the sum rules out.

说"这几样加起来在某预算内"之前,先把价格算一遍。 这条防的是模型最典型的翻车方式------目测加总。

text 复制代码
- Show an item the store cannot supply right now as unavailable, and introduce whatever
  you offer in its place as a stand-in.

缺货的要标成 unavailable,替代品要明确说是替代品,不能含混地当成原商品推荐

最后一节"当商品是买给别人时",有一条很妙:

text 复制代码
- ... a recall result about this recipient; a fact saved about a different person does
  not transfer.

记住的关于 A 的事实,不能套用到 B 身上。 这种约束写在技能里,比写在主提示词里更省上下文、也更不容易互相干扰------技能不加载,这条规则就不存在。


十、这套东西在 AI 大模型开发里的位置

讲了这么多实现细节,回到一个更根本的问题:这套东西在 AI 大模型开发里到底解决什么?

我的理解是,它回答的是"模型能力如何变成可靠的业务系统"这个中间层问题。大模型本身解决的是理解和推理,但一个能上生产的系统还需要另外四样东西,而它们全都不在模型里:

第一,事实必须来自系统,不能来自参数。 价格、库存、政策这些会变的东西,如果让模型从训练知识里答,必然出错。这套框架的解法是把它们全部收进 Backend 接口,模型只能看到方法返回值,而且返回值还经过 fencing。grounding gate 更进一步------命中政策/订单关键词时强制先读再答

第二,写入必须有边界。 模型可以建议,但不能直接改生产数据。这套框架给了三层:provenance(id 必须本会话见过)、options(家族不能直接加购)、caps(数量行数封顶),加上商家侧的 staged change。

第三,业务流程要能独立于模型迭代。 技能是 SKILL.md 文件,改一次流程等于改一个文件,不用发版、不用重训。这条路径近两年正在成为主流------改行为,不改权重

第四,模型不能接触它不该接触的东西。 结算 URL 在模型调用之后才被附上;凭证留在 host 侧;user id 不作为工具参数存在;目录文本必须待在围栏里。这四条是同一条原则的四个切面。

如果要给这个中间层起个名字,我倾向于叫它 Agent 工程------它关心的是状态、边界、幂等、可回滚、可审计,这些和传统后端工程是同一套语言,只是调用方从确定性代码变成了概率性模型。

对做大模型应用的人来说,可迁移的部分其实是这几条:

可迁移的做法 解决的问题
用接口签名划边界,而不是在 prompt 里写禁令 类型系统层面的约束比自然语言可靠
把"拦下"设计成 held(可恢复指引)而非 error 模型能自己纠正,任务流不中断
数据缺失返回 None + 说明,不返回 0 防止模型把占位值当事实复述
静态/动态 prompt 分离 prompt cache 命中率,直接决定成本
关键词命中时强制读一次再答 防幻觉的实卡,不是靠提示词恳求
业务规则外置成可版本化文件 改流程不用发版、可 review、可回滚

给你的落点

最后给几个动手时的具体建议。

起步别贪全。 文档明确给了最小集:购物侧先实现 search_productsget_product_details,其余方法 stub(返回 unavailable 即可,不改任何 prompt 字节);商家侧先实现 8 个读方法,写方法一律拒绝。这样摘要和指标能先跑起来,且没有写路径的风险。

先拿最大的家族测 12,000 字符。 变体超限会被静默截断,这是最容易在上线后才发现的坑。

算清楚你的价格字段名。 如果价格不叫 price(房价、票价),记得加进 price_bearing_fields,否则价格上限检查形同虚设。

验证缓存有没有生效。turn_completecache_read_input_tokens,第二轮是 0 就说明前缀变了。静态/动态拆分做得对不对,这个数字会直接告诉你。

scripts/ 里的工具自检。 check.pyverify_all.py(含部署 dry run 和 web 构建)、smoke_chat.py --vertical travel(跑一场真实对话,需要 key)。CI 会在两个 Python 版本上装 dev 依赖、构建八个 web 应用,并检查包名在公共索引上未注册。

最后提醒一句这个仓库的姿态:它不是 SDK,不会有人给你修 bug。 用它的正确方式是把它当成一份写得很细的设计文档 + 可运行参考,读完、跑通、然后照着它的结构写自己的实现,而不是把它当依赖装进生产环境。

#ClaudeCommerceAgents #StorefrontBackend #护栏 #商品变体建模 #grounding #SKILLmd #Agent工程

相关推荐
进击的横打1 小时前
【人工智能】AI时代公司组织架构的重构
大数据·人工智能·重构
枫彩1 小时前
WorkBuddy + 悟道 MCP:把盘后复盘保存成三个可对照的文件
人工智能·a股·股票数据·mcp·workbuddy
SimpleLearingAI1 小时前
DFL:分布焦点损失——让框回归“学分布“,而不只是“猜数字“
人工智能·数据挖掘·回归
唐兴通个人1 小时前
新华保险集团携手浙江大学,邀请唐兴通老师主讲AI时代b保险新媒体营销增长专项培训
人工智能
AIGC大时代1 小时前
文献驱动学发现:Scimon、Scideator与CHIMERA 怎么验收(Tom Hope)
人工智能·科技·机器学习
IT古董1 小时前
AI资讯日报|2026年9月7日:涉 AI 纠纷裁判规则出台,GPT-6 引领新一波能力升级,国产算力与智能体加速落地
人工智能
jimmyleeee2 小时前
大模型安全之六:LLM过度代理(Excessive Agency)
人工智能·安全
UCloud_TShare2 小时前
优刻得孔明智算平台携手openFuyao,加速多样化算力规模化落地
人工智能·ai·大模型
染指11102 小时前
110.Agent-LangChain核心组件-Messages消息和提示词工程
人工智能·microsoft·langchain·agents