KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 结果、结构化输出和用户输入如何返回? — keel 龙骨
## 现场:助手说"已处理",工单却没创建
现场:助手说"已处理",工单却没创建
跨年夜前一周,值班工程师 Newton 在对话框里说:
「帮我把 payments 的那个 P2 事件处理掉。」
助手回:「已经处理完成 ✅」。
第二天早会,工单系统里没有这张单,也没有任何 notification。翻日志才看到那次调用返回了一个完全正常的 HTTP 200 响应,内容是一段文本:「已受理」。
后来查清:那次调用在上游队列排队超时被丢弃了。而我们的 Tool 把「调用被受理」写成了给模型看的成功文本,模型没有能力区分「受理」和「完成」。
这个事故里没有任何一行代码是错的。错的是一个语义判断:把协议层的成功,当成业务层的完成。
直觉模型:成功至少有三层
把上面这句话展开,一次 Tool 调用至少有三个独立的成功概念:
| 层 | 问题 | 谁判断 |
|---|---|---|
| 协议层 | 我收到一条结构合法、能对上 id 的响应了吗? |
Client |
| 工具层 | 业务动作真的做完了吗?产生了什么副作用? | Server |
| 用户层 | 使用者看懂了结果吗?知道要不要接着做什么吗? | 宿主 UI / 模型 |
现场事故里,三层是塌缩的:工具层的「受理」被写成文本,穿过协议层毫无标记地到了用户层,被模型读成了完成。
防止塌缩的办法是把它们分别表达在同一个返回对象里。
精确定义:ToolResult 的三个通道
本课程的 Tool 结果对象:
@dataclass(frozen=True)
class ToolResult:
content: list[dict[str, Any]]
structured_content: dict[str, Any] | None = None
is_error: bool = False
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {"content": self.content}
if self.structured_content is not None:
result["structuredContent"] = self.structured_content
if self.is_error:
result["isError"] = True
return result
三个字段,对应三种读者:
content:给人/模型看的内容片段(文本等)。它是叙事层,允许口语化。structuredContent:给程序消费的结构化数据。它是事实层,字段要稳定。isError:给协议层看的判定标记。它是控制层,不能被口语化替代。
再看一次第 04 章那行输出,现在应该能读得更深:
{'content': [{'type': 'text', 'text': 'payments: 2 个已授权事件摘要'}],
'structuredContent': {'service': 'payments', 'count': 2, 'tenant': 'acme'}}
同一个事实被表达了两遍,各有用途:模型的回答依据 content;下游做「如果 count > 0 就告警」的逻辑读 structuredContent。如果只返回 content,下游只能去解析自然语言——那正是半夜崩的那种代码。
关键约束是:不能让模型从 content 里猜 isError。 文本里写「出错了」跟 isError: True 是两回事。
错误该是"结果"还是"抛异常"?
call_tool 里有一段很值得抄的设计:
required = definition.input_schema.get("required", [])
if any(not arguments.get(field) for field in required):
self.events.record("tool.rejected", tool=name, subject=principal.subject, reason="invalid_input")
return ToolResult([{"type": "text", "text": "required input is missing"}], is_error=True)
注意这里用的是 return ToolResult(..., is_error=True),不是 raise。
区别在使用体验上非常大:
- 返回错误结果:错误回到模型,模型看到之后有机会修正参数重试(把
service补上再来一次)。 - 抛异常:错误穿过调用链,可能中断整次运行。
选择标准可以这样说:模型有能力修复的问题,用错误结果表达;模型无能为力的问题(凭据无效、工具不存在、越权),用异常表达。 后者继续往上抛,交给 Client / 宿主处理。
对照实现:call_tool 里越权、租户不匹配、缺 scope 都抛 PermissionError(模型修不了),而缺必填字段返回 is_error 结果(模型能改)。
一次完整运行
python courses/foundation/mcp-protocol-engineering/course/project/examples/04_user_input.py
实跑输出:
waiting: {'taskId': '7f6fb03e-c89d-4f8b-82e0-0ef9e804b5ac', 'status': 'input_required', 'inputSchema': {'type': 'object', 'required': ['service']}}
invalid input: required task input is missing
accepted: {'taskId': '7f6fb03e-c89d-4f8b-82e0-0ef9e804b5ac', 'status': 'working', 'input': {'service': 'payments'}}
三行讲的是同一次诊断任务的三个阶段:
- 创建后立刻进入
input_required。返回值带上了taskId和inputSchema——注意inputSchema在这里是给调用方的填写说明,不是工具参数。它告诉客户端:"我需要一个service字段。" - 非法输入被拒。
- 合法输入被接收,状态推进到
working。注意taskId三行完全一致——同一个任务的延续,不是新开一个。
失败注入
注入 A:缺必填字段
result = server.call_tool("incident.search", {}, token, "acme")
print(result.to_dict(), "| is_error =", result.is_error)
实跑输出:
{'content': [{'type': 'text', 'text': 'required input is missing'}], 'isError': True} | is_error = True
没有抛异常。 这是一次合法的工具调用,得到了一个「失败了的结果」。调用方如果只看有没有异常、不看 isError,就会把这个结果当成成功——这正是现场事故的近亲。
自查清单:你的调用方代码在拿到 ToolResult 之后,第一件事是不是检查 isError? 如果不是,迟早会重演。
注入 B:任务处于错误状态时提交输入
working = store.create() # 没有 input_schema → 直接 working
store.provide_input(working.task_id, {"service": "payments"})
实跑输出:
ValueError: task is not waiting for input: working
状态机越界会响亮地失败,并且错误信息里带着当前状态——便于排查。对比第 08 章会看到的 complete / cancel:对终态的处理是静默返回,两条设计不一致的地方要格外留意。
用户输入:Elicitation 该怎样用
「Server 需要用户补充信息」这件事,官方在 2026-07-28 把它列在客户端能力里,原文表述是:
Elicitation: Server-initiated requests for additional information from users
(来源:modelcontextprotocol.io 规范总览 Features 章节,检索于 2026-09-29)
三个要点:
- 它是 Client 提供的能力,所以 Client 有权不给,用户有权不答。
- 它是服务端发起的请求——这需要一条真实的反向通道。
- 它必须能被拒绝,且拒绝要能正常收场。
本项目怎么教学的?答案是换了个等价的表达:服务端把「需要输入」写成任务状态 input_required,客户端轮询看到后主动提交。原因很实际——进程内函数调用没有反向通道,没法让服务端追着客户端要东西。
这是明确的教学替身,生产上的替换关系如下:
| 教学实现 | 生产替换 |
|---|---|
任务状态 input_required + 轮询 |
Client 能力 Elicitation + UI 确认组件 |
inputSchema 描述需要什么字段 |
带类型、校验、脱敏和超时策略的表单 |
| 服务端等待 | 会话绑定的请求 + 过期处理 |
一个必须画红线的地方
无论用哪种机制,都不能把 Elicitation 当成万能输入框。它请求的是完成任务所必需的作业参数,不是凭据。
下面这些都是把机制用坏的例子:
- 「请再输入一次你的密码以继续」——凭据应从授权环节来,不从对话里来;
- 「请把这个页面的完整 HTML 粘贴过来」——这是在诱导用户把任意数据灌进上下文;
- 「请确认是否允许我删除所有文件」——危险动作的确认应该是 UI 层的明确动作,而不是一段可以被提示注入模仿的文本。
配套的宿主侧要求是:用户输入必须显示为明确的确认/输入组件,而不是和助手文本混在一起;输入内容进日志前必须脱敏。
生产替换点
| 教学实现 | 生产替换 |
|---|---|
| 文本 + 简单 dict | content / structuredContent / isError 三通道齐全,含资源引用 |
| 错误即返回文本 | 错误分类:输入错误 / 权限 / 依赖不可用 / 结果未知 |
| 服务端「受理」= 成功 | 区分 accepted 与 completed,前者返回引用而非完成承诺 |
input_required 轮询 |
Elicitation + 表单组件 + 过期与脱敏日志 |
练习与验收
练习(有可观察结果):给 ToolResult 加一个训练——写一个 describe(result) 函数,输入 ToolResult,输出一句「给用户看的话」。要求:is_error 为 True 时必须以「未成功」开头;有引用时必须带上引用;有 structuredContent 且 count == 0 时必须说清"没有匹配数据"而不是"已处理"。
验收标准:拿注入 A 的返回值跑一遍,你的函数必须输出包含「未成功」的句子。
本章检查点
- 现场那个「已处理」事故,如果结果对象里带了
isError和structuredContent,会发生什么不同? - 为什么缺必填字段返回错误结果、而越权抛异常?换过来会怎样?
- 你的 Elicitation 表单要收集一个 API Key。该做还是不该做?如果要做,前提是哪些控制到位?
现在能解释什么
你现在能把「成功」拆成协议层、工具层、用户层三层,并且知道用 content / structuredContent / isError 三个通道分别表达。你也理解了为什么输入错误要用错误结果而不是异常——因为模型能修。Elicitation 部分你知道本项目用任务状态做了替身,生产要用客户端能力。下一章把目光移到承载这些消息的管道本身:stdio 与 HTTP 各自的威胁模型,以及「用户点了停止」之后到底发生了什么。