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

三个字段,对应三种读者:

再看一次第 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。

区别在使用体验上非常大:

选择标准可以这样说:模型有能力修复的问题,用错误结果表达;模型无能为力的问题(凭据无效、工具不存在、越权),用异常表达。 后者继续往上抛,交给 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'}}

三行讲的是同一次诊断任务的三个阶段:

  1. 创建后立刻进入 input_required。返回值带上了 taskId 和 inputSchema——注意 inputSchema 在这里是给调用方的填写说明,不是工具参数。它告诉客户端:"我需要一个 service 字段。"
  2. 非法输入被拒。
  3. 合法输入被接收,状态推进到 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)

三个要点:

  1. 它是 Client 提供的能力,所以 Client 有权不给,用户有权不答。
  2. 它是服务端发起的请求——这需要一条真实的反向通道。
  3. 它必须能被拒绝,且拒绝要能正常收场。

本项目怎么教学的?答案是换了个等价的表达:服务端把「需要输入」写成任务状态 input_required,客户端轮询看到后主动提交。原因很实际——进程内函数调用没有反向通道,没法让服务端追着客户端要东西。

这是明确的教学替身,生产上的替换关系如下:

教学实现 生产替换
任务状态 input_required + 轮询 Client 能力 Elicitation + UI 确认组件
inputSchema 描述需要什么字段 带类型、校验、脱敏和超时策略的表单
服务端等待 会话绑定的请求 + 过期处理

一个必须画红线的地方

无论用哪种机制,都不能把 Elicitation 当成万能输入框。它请求的是完成任务所必需的作业参数,不是凭据。

下面这些都是把机制用坏的例子:

配套的宿主侧要求是:用户输入必须显示为明确的确认/输入组件,而不是和助手文本混在一起;输入内容进日志前必须脱敏。

生产替换点

教学实现 生产替换
文本 + 简单 dict content / structuredContent / isError 三通道齐全,含资源引用
错误即返回文本 错误分类:输入错误 / 权限 / 依赖不可用 / 结果未知
服务端「受理」= 成功 区分 accepted 与 completed,前者返回引用而非完成承诺
input_required 轮询 Elicitation + 表单组件 + 过期与脱敏日志

练习与验收

练习(有可观察结果):给 ToolResult 加一个训练——写一个 describe(result) 函数,输入 ToolResult,输出一句「给用户看的话」。要求:is_error 为 True 时必须以「未成功」开头;有引用时必须带上引用;有 structuredContent 且 count == 0 时必须说清"没有匹配数据"而不是"已处理"。

验收标准:拿注入 A 的返回值跑一遍,你的函数必须输出包含「未成功」的句子。

本章检查点

现在能解释什么

你现在能把「成功」拆成协议层、工具层、用户层三层,并且知道用 content / structuredContent / isError 三个通道分别表达。你也理解了为什么输入错误要用错误结果而不是异常——因为模型能修。Elicitation 部分你知道本项目用任务状态做了替身,生产要用客户端能力。下一章把目光移到承载这些消息的管道本身:stdio 与 HTTP 各自的威胁模型,以及「用户点了停止」之后到底发生了什么。

进入 keel 阅读