KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · Message、Part 与 Artifact:一次委派携带什么,又取回什么 — keel 龙骨
## 现场:结论写在句子里,下游去解析自然语言
现场:结论写在句子里,下游去解析自然语言
变更分析 Agent 干完活,回了一条消息:「payments 有 2 条相关事件,其中 1 条未恢复,建议关注 deploy-4471。」
SRE 的编排层需要「未恢复事件数量」来决定要不要升级告警。没有结构化字段,于是有人写了这样一行:
count = int(re.search(r"(\d+) 条未恢复", text).group(1))
前两周没问题。第三周,远端 Agent 把措辞改成了「2 条事件中 1 条仍在观察中」。正则匹配不到,异常被吞掉,编排层按「0 条未恢复」处理——该升级的告警没升级。
故障的根因不是正则写得脆,而是把机器要读的事实塞进了给人读的句子里。A2A 用两个不同的对象分开这两件事:Message 和 Artifact。
直觉模型:说的话 vs 交出来的东西
| 是什么 | 谁读 | 稳定性要求 | |
|---|---|---|---|
| Message | 一次沟通回合:指令、追问、进度说明 | 人和模型 | 允许口语化 |
| Artifact | 任务的产物:报告、文件、结构化结果 | 程序 | 字段要稳定 |
一句话:Message 是过程,Artifact 是交付物。 现场那个事故,就是把交付物写成了过程。
精确定义
Part:v1.0 最疼的一处重构
v0.3 有三种 part 类型加一个 kind 判别器;v1.0 合并成单一 Part,用「哪个字段存在」来判别:
| 字段 | 内容 |
|---|---|
text |
纯文本 |
raw |
内联二进制(JSON 里是 base64) |
url |
外部文件引用 |
data |
结构化 JSON 值 |
必须且只能携带其中一个。 另外三个字段对所有 part 通用:mediaType(v1.0 改名自 mimeType)、filename、metadata。
判别方式的变化直接改写解析代码:
# v0.3
if part.kind == "text": ...
# v1.0
if "text" in part: ...
这条改动看着琐碎,却是跨版本对接时最常见的空指针来源:老代码读 part.kind,新消息里根本没有这个键。
一次委派的分工
Message(role=ROLE_USER)—— 我方提出要求,可携带多种 Part
↓
Remote Agent 在内部干活(我看不见)
↓
情况甲:当场答完 -> 返回一个 Message
情况乙:要长跑 -> 返回一个 Task(带 id 与状态),产物随后以 Artifact 交付
什么时候返回 Message、什么时候返回 Task,由服务端决定。 调用方必须两种都处理——只处理一种是最常见的接入 bug。
全链路图
flowchart TD
subgraph CLIENT["Client Agent"]
BUILD["① 组装 Message 与 Parts"] --> SEND["② SendMessage"]
end
subgraph SERVER["Remote Agent 不透明"]
PARSE["③ 解析 Parts"]
IMM{"④ 能当场答完吗"}
EXEC["⑤ 受理为 Task 并推进"]
end
SEND --> PARSE
PARSE -->|Part 不合法 0 个或 2 个内容字段| REJECT["ValueError 拒绝"]
PARSE --> IMM
IMM -->|能| MSG["⑥ 返回 Message"]
IMM -->|不能| EXEC
EXEC --> TASK["⑦ 返回 Task 句柄"]
EXEC -->|缺输入| WAIT["⑧ 状态转 INPUT_REQUIRED 等待补输入"]
EXEC -->|完成| ART["⑨ 附加 Artifact 产物"]
MSG --> CLIENT2["取回:直接读 Parts"]
TASK --> POLL["取回:轮询或流式或推送"]
ART --> POLL
WAIT -.->|补发消息| SEND
REJECT -.-> CLIENT2
一次完整运行
python courses/foundation/a2a-protocol-engineering/course/project/examples/03_parts_artifacts.py
实跑输出:
A. Part 必须且只能携带一种内容:
纯文本 ok -> {'text': 'hello'}
空 Part 拒绝 -> a Part must hold exactly one content field (text/raw/url/data), got 0: []
text + data 同时给 拒绝 -> a Part must hold exactly one content field (text/raw/url/data), got 2: ['text', 'data']
外部文件引用 ok -> {'url': 'https://files.acme.internal/a.pdf', 'mediaType': 'application/pdf', 'filename': 'a.pdf'}
结构化数据 ok -> {'data': {'service': 'payments'}, 'mediaType': 'application/json'}
B. 判别方式变了:
v0.3 看 part.kind;v1.0 看字段是否存在 -> 'text' in part = True | carrier=text
C. Artifact 可以混合承载:
承载类型: ['text', 'data']
D. Message 的 role 枚举(v1.0 起 SCREAMING_SNAKE):
{'role': 'ROLE_USER', 'parts': [{'text': '诊断 payments'}], 'messageId': 'msg-01'}
E. 同样的 SendMessage,两种返回:
能当场答完 -> ['message']
要长跑 -> ['task']
逐段读:
- A 是 oneof 约束的两头:空 Part 和双字段 Part 都被拒绝,且错误信息里说清了到底给了几个。这个约束在实现层强制执行,不靠约定。
- B 是判别方式:
carrier只是本项目为讲解加的只读属性,协议上判别靠字段存在性。 - C 说明 Artifact 可以混合承载——一句给人读的摘要 + 一份给程序读的结构化数据,同时交付。这正是现场那个事故的正解。
- E 是本章最该记住的一行:同一个
SendMessage,返回体里可能是message也可能是task。调用方如果写成response["result"]["task"],在「能当场答完」的分支上会直接 KeyError。
失败注入
注入 A:把事实塞进文本
把结构化结果改成「payments 有 2 条事件」这样的文本 part 交回去,然后让下游解析。你会重演现场的事故。
判断标准:给程序读的字段必须落在 data part 里,并且有稳定的键名。 文本 part 只服务于人和模型的阅读理解。
注入 B:以为 Part 一定有 kind
从 v0.3 迁移时最容易写出来的代码:
kind = part.get("kind") # v1.0 里永远是 None
if kind == "data":
process(part["data"])
在 v1.0 上,kind 恒为 None,分支永远不进,程序不报错,只是什么都不做。这类 bug 的排查成本远高于崩溃。
生产边界
| 教学实现 | 生产替换 |
|---|---|
| 内存里的 Part 字典 | 真实 HTTP 载荷;大文件走 url 引用而不是 raw 内联 |
| 固定 mediaType | 按 defaultInputModes / defaultOutputModes 协商,不匹配要明确拒绝 |
| 教学用固定产物 | 真实模型产出,仍需保证 data part 的键名稳定 |
| 无大小限制 | 单 part 大小上限、总载荷上限、URL 白名单 |
练习与验收
练习(有可观察结果):写一个 extract_facts(message) 函数,输入一个 Message,返回它所有 data part 合并后的字典。要求:① 完全没有 data part 时返回空字典而不是抛异常;② 多个 data part 键冲突时,后者不覆盖前者,而是把冲突键收集到一个列表里返回。
验收标准:构造一个带两个冲突 data part 的 Message,断言你的函数不会静默丢掉其中一个的值。静默覆盖在生产里等于「结论被后写的覆盖了」,而且没人知道。
本章检查点
- 现场那个正则事故,如果结论同时以
datapart 交付,故障会在哪一步消失? - 为什么
SendMessage可能返回 Message 也可能返回 Task?只处理一种会怎样? - v0.3 的
part.kind在 v1.0 上会返回什么?这类 bug 为什么难查?
现在能解释什么
你现在能把「过程」和「交付物」分开:Message 承载沟通,Artifact 承载结果,Part 是它们共同的容器,且必须四选一携带内容。你也知道同一个 SendMessage 有两条返回路径,调用方必须都处理——而这只是任务的开始:返回 Task 意味着你要开始跟踪一个可能跑很久的东西。下一章就讲这个状态机。