跳到主内容
快讯直播
AI智模界
教程

用 AP2 沙箱搭一个会自己比价下单的购物智能体

这篇能做出什么

跑完这篇教程,你会得到一个能在 AP2 沙箱里端到端走通的购物智能体:给它一句自然语言需求(例如"帮我买一副降噪耳机,5 天内能到,到手价不超过预算"),它会自动完成下面这条链路——

1. 向多个商家源并发检索商品,把不同格式的报价归一化成同一种数据结构;

2. 用确定性代码比价(含运费、时效、库存),产出一份可解释的决策记录;

3. 把用户的预算与约束写成一张 Intent Mandate(意图授权),由用户私钥预签名;

4. 选定商品后生成 Cart Mandate(购物车授权),在人工确认后签名;

5. 向 Credentials Provider 换取 Payment Mandate(支付授权),拿到支付凭证;

6. 走沙箱支付通道,回收一张可验签的交易凭证,写进审计日志。

沙箱环境不产生真实扣款,所有支付凭证都是测试凭证。整条链路的验收清单:

  • [ ] 一句需求能检索到 ≥2 个商家的报价
  • [ ] 比价结果有明确排序理由,且人工可以复算
  • [ ] Intent Mandate 能通过本地验签
  • [ ] 超出意图约束时,流程会停下来等人确认,而不是硬闯
  • [ ] 能拿到交易凭证,且验签通过
  • [ ] 全流程有一份可回放的 JSONL 审计日志

前置条件清单

类别需要什么说明
运行时Python 3、Docker 与 Docker Compose具体版本以官方文档当前版本为准
工具git、curl、jqjq 用来读沙箱返回的 JSON,排查效率高很多
凭据AP2 沙箱的测试账号与测试凭证由沙箱签发,不要填真实卡号
网络能访问沙箱入口与示例商家服务公司网络注意放行
知识看得懂 JSON、改得动 Python 脚本、知道非对称签名是什么不需要密码学背景

建议的目录结构:

```text

ap2-shopping-agent/

├─ .env # 沙箱地址、密钥路径、商家源

├─ search.py # 检索与归一化

├─ decide.py # 比价与决策

├─ mandates.py # 三张 Mandate 的构造与验签

├─ checkout.py # 支付与凭证回收

├─ audit.jsonl # 审计日志

└─ keys/ # 本地测试密钥,务必写进 .gitignore

```

先认角色:谁在跟谁说话

AP2 的场景里有这么几个角色,理清楚它们,后面写代码就不会乱:

  • User(用户):出钱的人,也是唯一能给授权的人。
  • Shopping Agent(购物智能体):跑在你的机器上,代表用户检索、比价、发起下单。
  • Merchant Agent(商家智能体):商家侧的对话入口,负责报价、生成购物车。
  • Credentials Provider(CP,凭证提供方):负责签发支付凭证,类似发卡行或钱包的角色。
  • Payment Processor(支付处理方):真正执行扣款并把交易结果回传。

三张 Mandate 是这套协议的核心,本质上是"授权书":

  • Intent Mandate(意图授权):用户预先签好的约束条件——买什么品类、找哪些商家、预算上限、收货时效、有效期。它解决的是"人不在场时,智能体凭什么可以自己动"。
  • Cart Mandate(购物车授权):针对一次具体下单的确认。人不在场时,它需要引用 Intent Mandate 并落在其约束范围内;人在场时,可以直接由用户签。
  • Payment Mandate(支付授权):把购物车和支付方式绑定起来,交给 CP 去换支付凭证。它里面会引用购物车的内容哈希,防止被调包。

顺带说明一个常见混淆:AP2 常和 A2A、MCP 一起出现。A2A 管"智能体之间怎么通信",MCP 管"智能体能调用哪些工具和数据",AP2 管"凭什么可以付这笔钱"。三者分工不同,不要互相替代。

步骤 1:把沙箱跑起来

从官方仓库获取示例代码并启动。仓库地址、镜像 tag、内部端口一律以官方文档与仓库 README 当前内容为准,下面用占位符表示。

```bash

git clone <AP2 官方 samples 仓库地址>

cd <仓库目录>

cp .env.example .env

```

打开 .env,按 README 的说明填入沙箱入口地址与测试凭证,然后启动:

```bash

docker compose up -d

docker compose ps # 确认各服务状态是 running

```

健康检查:

```bash

curl -sS "$AP2_SANDBOX_BASE_URL/health" | jq .

```

返回里出现成功标识,说明沙箱已就绪。把环境变量导出到当前 shell:

```bash

set -a; source .env; set +a

```

Python 侧的依赖:

```bash

python -m venv .venv

source .venv/bin/activate # Windows: .venv\Scripts\activate

pip install httpx pydantic cryptography fastapi uvicorn

```

步骤 2:检索层——把各家的报价归一化

不同商家返回的字段名、金额格式、运费规则都不一样。先定义统一的报价对象,把脏活都关在这一层。

```python

search.py

import asyncio, os

import httpx

from dataclasses import dataclass

from decimal import Decimal

@dataclass(frozen=True)

class Offer:

merchant_id: str

sku: str

title: str

unit_price: Decimal

currency: str

shipping: Decimal

eta_days: int

stock: int

url: str

@property

def landed_cost(self) -> Decimal:

"""到手价 = 单价 + 运费。后续所有比较都用这个字段。"""

return self.unit_price + self.shipping

SOURCES = [

{"id": "merchant-a", "base_url": os.environ["MERCHANT_A_URL"]},

{"id": "merchant-b", "base_url": os.environ["MERCHANT_B_URL"]},

]

def parse_offer(merchant_id: str, raw: dict) -> Offer:

return Offer(

merchant_id=merchant_id,

sku=raw["sku"],

title=raw["title"],

unit_price=Decimal(str(raw["price"]["amount"])),

currency=raw["price"]["currency"],

shipping=Decimal(str(raw.get("shipping", {}).get("amount", "0"))),

eta_days=int(raw.get("eta_days", 99)),

stock=int(raw.get("stock", 0)),

url=raw["url"],

)

async def search_one(client, source, query):

try:

resp = await client.post(

f"{source['base_url']}/search",

json={"query": query, "limit": 20},

timeout=8.0,

)

resp.raise_for_status()

return [parse_offer(source["id"], item) for item in resp.json()["items"]]

except (httpx.HTTPError, KeyError, ValueError) as exc:

print(f"[warn] {source['id']} 检索失败:{exc}")

return []

async def search_all(query: str) -> list[Offer]:

async with httpx.AsyncClient() as client:

groups = await asyncio.gather(*(search_one(client, s, query) for s in SOURCES))

return [offer for group in groups for offer in group]

```

两个关键点:金额一律用 Decimal,绝不用 float;单个商家检索失败只告警不中断,否则一家挂了整条链路就瘫了。

步骤 3:决策层——让代码算钱,让模型说话

比价这种有唯一正确答案的事,交给确定性代码;大模型只负责把结论翻译成人话,以及处理"用户需求模糊"这类问题。顺序反过来会埋雷:模型算错一次价格,后面所有授权都是错的。

```python

decide.py

from dataclasses import dataclass

from decimal import Decimal

from search import Offer

@dataclass

class Decision:

chosen: Offer

rejected: list[tuple[Offer, str]]

reason: str

def rank(offers: list[Offer], constraints: dict) -> Decision:

max_cost = Decimal(str(constraints["max_landed_cost"]))

currency = constraints["currency"]

max_eta = constraints["max_eta_days"]

ok, rejected = [], []

for o in offers:

if o.currency != currency:

rejected.append((o, f"币种不符:{o.currency}"))

elif o.stock <= 0:

rejected.append((o, "无库存"))

elif o.landed_cost > max_cost:

rejected.append((o, f"到手价 {o.landed_cost} 超预算 {max_cost}"))

else:

ok.append(o)

if not ok:

raise RuntimeError("没有满足约束的报价,转人工处理")

排序键:先看时效是否达标,再看到手价,最后看时效快慢

ok.sort(key=lambda o: (o.eta_days > max_eta, o.landed_cost, o.eta_days))

best = ok[0]

return Decision(

chosen=best,

rejected=rejected,

reason=f"在预算内且时效达标,选择 {best.merchant_id},到手价 {best.landed_cost} {best.currency}",

)

```

把每次决策写进审计日志,格式用 JSONL,一行一条,方便回放:

```python

audit.py

import json, time

def append_audit(path: str, **event):

event["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

with open(path, "a", encoding="utf-8") as f:

f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")

```

调用时记录候选数量、被拒原因、最终选择。三个月后你回看这些日志,才知道当时那个"看起来奇怪"的下单是怎么发生的。

步骤 4:Intent Mandate——把预算变成可签名的授权

Intent Mandate 是用户提前签好的"作业边界"。下面是一份结构示意,实际字段名与 schema 以官方文档为准。

```json

{

"type": "intent_mandate",

"mandate_id": "<uuid>",

"issuer": "user:<用户标识>",

"shopping_agent": "agent:<智能体标识>",

"constraints": {

"categories": ["headphones"],

"merchants_allowed": ["merchant-a", "merchant-b"],

"currency": "USD",

"max_landed_cost": "199.00",

"max_eta_days": 5,

"max_transactions": 1,

"expires_at": "2030-01-01T00:00:00Z"

},

"signature": { "alg": "ES256", "kid": "<key-id>", "value": "<base64url>" }

}

```

拿到沙箱返回的 Mandate 后,先在本地验签,确认它确实由用户私钥签发、且内容没被改动:

```python

mandates.py

from cryptography.exceptions import InvalidSignature

from cryptography.hazmat.primitives import hashes

from cryptography.hazmat.primitives.asymmetric import ec

def verify_es256(public_key, signing_input: bytes, signature: bytes) -> bool:

"""signing_input 是被签名载荷的规范化字节串,签名算法与沙箱配置保持一致。"""

try:

public_key.verify(signature, signing_input, ec.ECDSA(hashes.SHA256()))

return True

except InvalidSignature:

return False

```

还要做一道业务校验:当前时间是否在 expires_at 之前、品类是否匹配、金额是否超限。协议验签只证明"这封授权书没被改",不证明"这次下单符合授权书"。这两件事必须分开做。

步骤 5:Cart Mandate 与那个人工确认点

选定商品后,构造 Cart Mandate,内容至少包含:商家标识、商品与数量、到手价、币种、预计时效、以及引用的 Intent Mandate ID。

这是整条链路里第一个必须留人工确认的环节。 确认界面应该把信息摊开,而不是只问一句"确认吗":

```text

即将下单:

商家 merchant-a

商品 Noise-Cancelling Headphones X1 × 1

到手价 178.00 USD(单价 168.00 + 运费 10.00)

预计送达 3 个工作日

预算上限 199.00 USD(剩余 21.00)

授权来源 intent_mandate <id>(剩余可用次数 1)

[y] 确认并签名 [n] 取消 [e] 换一个候选

```

如果候选商品超出了 Intent Mandate 的约束(超预算、商家不允许、时效不达标),流程必须停下来重新征求用户授权,而不是自动放宽条件。做法是让 rank() 直接抛异常,由外层提示用户重新签发 Intent Mandate。

步骤 6:Payment Mandate 与交易凭证签发

购物车被用户签名后,把 Cart Mandate 交给 Credentials Provider 换取支付凭证。请求体的字段名以官方文档为准,形状大致是:

```bash

curl -sS -X POST "$AP2_SANDBOX_BASE_URL/credentials/issue" \

-H "Content-Type: application/json" \

-H "Authorization: Bearer $AP2_SANDBOX_TOKEN" \

-d @payment_request.json | jq .

```

payment_request.json 里要带上购物车内容哈希和 Cart Mandate 的引用。两个硬性检查:

1. 哈希一致:Payment Mandate 里引用的购物车哈希,必须和实际提交给商家的购物车逐字节对得上。对不上就中止——这说明中间有人改过商品或金额。

2. 幂等:用 Cart Mandate ID 作为幂等键(例如 hashlib.sha256(cart_mandate_id.encode()).hexdigest())。网络超时重试时,处理器靠它识别出"这是同一笔",避免重复下单。

支付完成后回收交易凭证,用它自带的公钥或 CP 提供的公钥验签,验签通过再把结果写进审计日志:

```python

append_audit("audit.jsonl",

event="payment_settled",

cart_mandate_id=cart_id,

payment_mandate_id=pay_id,

credential_id=cred_id,

verified=verify_es256(cp_public_key, signing_input, signature))

```

必须留人工确认的环节

环节为什么必须留人
Intent Mandate 首次签发与额度变更这是全部自动化的授权源头,扩大额度等于扩大风险面
Cart Mandate 最终确认金额、商家、时效三要素具象化之后的确认,是唯一能拦住"理解偏差"的关卡
首次合作的新商家没有历史记录,退货与售后成本未知
超出意图约束的任何情况超预算、超时效、跨币种、跨境税费变化
订阅、自动续费、周期性扣款一次确认可能变成长期扣款
取消、退货、退款发起影响资金与账户状态,且往往不可逆
提升单笔或累计限额风险敞口放大,需要重新评估

反过来,检索、比价、排序、生成候选这些只读且可回滚的动作,可以放心交给智能体自己跑。

常见坑与排错

金额用浮点数。0.1 + 0.2 那套问题在钱上会变成真事故。统一 Decimal,或者干脆用最小货币单位的整数(分)。

只验签不验约束。签名有效只说明"授权书没被改",不代表"这次下单在授权范围内"。两道校验缺一不可。

忘记校验跨 Mandate 绑定。Cart Mandate 要引用 Intent Mandate,Payment Mandate 要引用 Cart Mandate 的内容哈希。少了这层绑定,攻击者可以把一份合法授权挪用到另一笔交易上。

时间戳与时钟偏移导致授权被判过期。沙箱机器与本地机器时区不一致时很容易踩到,统一用 UTC,并在校验时留一点容差。

重试造成重复下单。任何写操作都要带幂等键,客户端超时重试前先查询订单状态。

把私钥提交进了 git。.gitignore 里先写好 keys/ 和 .env,再开始写代码。

沙箱数据被重置。重置后旧的 mandate_id 会失效,报错时先确认沙箱是否被重启过。

排查顺序建议固定下来:先看 docker compose ps 与容器日志,再看审计日志里最后一个成功事件,最后才怀疑代码逻辑。这条顺序能省掉大量时间。

下一步建议

1. 加回归集:攒 20 条真实需求,每次改完比价逻辑跑一遍,看选择结果是否发生非预期漂移。

2. 把工具 MCP 化:检索、下单、查询订单状态封装成 MCP 工具,智能体换框架时这部分不用重写。

3. 做审批流:把人工确认从命令行搬到 IM 或审批系统,支持超时自动取消。

4. 接入告警:连续失败、金额异常、验签失败三类事件直接推送到值班渠道。

5. 看官方文档的扩展路径:AP2 覆盖多种支付轨道,具体支持范围与接入方式以官方文档当前版本为准。

把这条链路跑通一次,你对"智能体能不能自己花钱"这个问题的判断,会比读十篇文章都扎实。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。