AI 技术
#MCP#Elicitation#JSON Schema#工具调用#人机交互

MCP Elicitation 中途索要参数:工具执行到一半如何安全地向人类追问

工具跑到一半才发现缺少必填参数,直接报错会让用户重来一遍。本文以订位工具为例,拆解 MCP elicitation/create 如何暂停调用、用受限 JSON Schema 收集结构化输入并恢复执行,分析 accept、decline、cancel 三条路径、schema 校验失败与超时处理、敏感字段为何必须走 URL 模式,并给出可观测指标与适用边界。

一个订位工具卡在最后一步

假设我们有一个 MCP 订位工具 book_table,参数是日期和人数。模型把 date 和 party_size 都填好了,服务端也连上了餐厅系统。查询返回:当天没有空位。

这时工具面临一个选择。它可以返回一句“2025-12-25 无空位”,让模型自己决定要不要换日期。模型可能再调一次工具,也可能直接编一个“已为你改到 12 月 26 日”的回复。后一种情况下,用户看到的是假成功。

另一种选择是让工具停下来,直接问用户:“这天满了,要不要换到 12 月 26 日?”用户点确认,工具带着新日期继续跑完。这个“中途停下来问一句”的能力,就是 MCP 的征询(Elicitation)。

它解决的问题很具体:工具执行到一半,发现某个决策只能由人来做。这个决策可能是缺一个参数,也可能是需要确认一个有副作用的操作。关键在于,问题发生在同一次工具调用内部,用户的回答回到同一个函数调用里,而不是让模型重新发起一轮对话。

基线方案为什么不够

在征询出现之前,工具遇到缺参数通常只有两条路。

第一条是把缺的参数写进工具输入模式,让模型在调用前就填好。这要求模型提前知道所有需要的值。订位场景里,模型不知道当天满不满,它无法预判需要备选日期。把 alternative_date 设为必填,会让绝大多数正常调用白白多问一次。

第二条是直接返回错误,让模型读错误信息后重新调用。这条路的代价是上下文里多了一次失败记录,而且模型可能把“无空位”误读成“参数格式错误”,反复重试同一个日期。工具本身没有机会解释“换一天就行”。

两种基线都把决策权交给了模型。征询把决策权交回给人:工具用一段人类可读的消息和一个结构化的表单描述它需要什么,客户端负责渲染、收集、校验,再把结果送回工具。

请求如何从服务端走到用户再走回来

征询的核心是一次嵌套请求。服务端在处理工具调用期间,向客户端发一条 elicitation/create 请求,然后等客户端返回。

以订位场景为例,服务端发出的请求大致是这样:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "elicitation/create",
  "params": {
    "message": "2025-12-25 没有 {party_size} 人的空位,要换一天吗?",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "accept_alternative": {
          "type": "boolean",
          "description": "Try another date?"
        },
        "date": {
          "type": "string",
          "default": "2025-12-26",
          "description": "Alternative date (YYYY-MM-DD)"
        }
      },
      "required": ["accept_alternative"]
    }
  }
}

message 是给人看的,requestedSchema 是给客户端渲染表单和校验用的。客户端拿到后弹出对话框,用户勾选“换一天”并确认日期,返回:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "action": "accept",
    "content": { "accept_alternative": true, "date": "2025-12-26" }
  }
}

服务端收到 content 后,用新日期重新执行订位逻辑。如果新日期也满了,可以再问一次,而不是盲目确认。

整个流程里,工具函数是挂起的。它没有返回,也没有释放调用栈,只是在 await 上等客户端。这就是为什么工具必须是异步函数:它要在中途等一个人。

flowchart TD
    A[模型调用 book_table] --> B[服务端查询 2025-12-25]
    B --> C{有空位?}
    C -- 有 --> D[直接返回订位成功]
    C -- 无 --> E[发送 elicitation/create]
    E --> F[客户端渲染表单]
    F --> G{用户操作}
    G -- accept --> H[校验 content 是否符合 schema]
    H -- 通过 --> I[带新日期重新执行 book_table]
    H -- 不通过 --> J[返回校验错误]
    G -- decline --> K[工具返回未订位]
    G -- cancel --> L[工具返回未订位]
    I --> M{新日期有空位?}
    M -- 有 --> D
    M -- 无 --> E

图里有一个容易忽略的转折:accept 之后还有一次 schema 校验。客户端提交的内容不一定可信,服务端必须自己再验一遍。

schema 的表达力边界

征询表单用的是一份受限的 JSON Schema。它只支持扁平对象,属性只能是原始类型:字符串、数字、布尔值,以及字符串枚举。

字符串可以带 minLength、maxLength,也可以带 format,目前支持 email、uri、date、date-time 四种。数字可以带 minimum 和 maximum。布尔值可以带 default。枚举用 enum 列出候选值,也可以用 oneOf 给每个值配一个显示标题。

这个限制是有意的。客户端要能自动生成表单,就不能面对任意深度的嵌套结构。如果答案需要嵌套,比如一个地址对象里再套省市区,那它本该是工具的参数,而不是征询的内容。

在 Python SDK 里,这个边界会直接暴露成运行时错误。如果你在 Pydantic 模型里再放一个模型,ctx.elicit 会在任何东西发给客户端之前抛出异常,服务器日志里能看到类似这样的报错:

TypeError: Elicitation schema field 'address' rendered as
{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition

工具调用以 Error executing tool <name> 失败。这类错误应该在开发阶段就发现,而不是等用户触发。一个实用的做法是给所有征询模型写单元测试,断言它们能被序列化成合法的扁平 schema。

三种回答与它们的处理方式

征询的响应只有三种 action。

accept 表示用户提交了表单,content 里是符合 schema 的数据。decline 表示用户明确拒绝,比如点了“拒绝”或“不”。cancel 表示用户没有做出选择就关掉了对话框,比如按了 Escape 或者点了窗口外面。

后两种通常不带 content。它们的区别在于语义:decline 是一个决定,cancel 是一次中断。工具应该分别处理。订位场景里,decline 可以回复“好的,不订了”,cancel 可以回复“稍后可以再试”,甚至允许用户过一会儿重新发起。

拒绝不是错误。工具要自己决定拒绝意味着什么,然后正常回答模型。把 decline 当成异常抛出,会让模型看到一次失败的工具调用,可能触发它换个方式再问一遍,反而骚扰用户。

校验失败是另一条路径。如果客户端给布尔字段发来字符串 "maybe",服务端按 schema 校验时会抛 ValueError,工具调用失败。这个失败发生在工具的 if 判断之前,所以业务代码根本不会执行。这是好事:脏数据不会污染订位逻辑。但它也意味着,客户端如果实现不规范,用户会看到一次莫名其妙的工具失败。服务端能做的,是在错误信息里说清楚哪个字段不符合哪种约束。

敏感字段为什么不能走表单

规范里有一条硬性要求:服务端不得通过表单模式征询敏感信息,包括密码、API 密钥、访问令牌和支付凭据。这类交互必须走 URL 模式。

原因在于数据流向。表单模式下,用户输入的内容会经过 MCP 客户端,也会进入模型可见的上下文。一个 API 密钥一旦被填进表单,它就可能出现在日志、会话记录和后续的模型输入里。

URL 模式换了一种做法。服务端不索要数据,而是请用户去一个外部地址完成操作。客户端只负责显示目标域名、征得用户同意、打开浏览器。用户在那边做了什么,不经过 MCP 协议。

订位场景里,押金支付就属于这一类。工具返回一条消息和一个支付链接,用户点“同意打开”后跳转。这里有一个关键区别:accept 只表示用户同意打开这个 URL,不表示他们完成了支付。服务端不能因为收到 accept 就认为钱到账了。真正的支付结果要由支付方通过另一个入口回报,比如调用 confirm_deposit 之类的工具来记录。

普通联系方式不属于敏感信息。姓名、邮箱、用户名是否通过表单收集,由服务端决定,前提是用户能查看、修改并拒绝。

一次性补全还是多轮追问

征询不是唯一的选择。工程上至少还有两种替代方案,各自适合不同的场景。

方案何时提问延迟特征上下文开销适合的场景主要风险
一次性参数补全调用前,由模型填齐所有参数无额外往返参数模式常驻上下文参数可预判且数量固定模型猜错参数,或为罕见分支多问一次
工具内征询执行到需要时,由工具发起每次征询增加一次用户往返只在实际触发时产生决策依赖运行时结果用户不在场时阻塞,需要超时兜底
多轮对话追问工具返回后,由模型组织下一轮至少多一轮模型推理失败记录和追问都进上下文问题开放、需要解释模型可能自行编造答案

一次性补全适合参数空间小、模型能预判的情况。比如一个查询天气的工具,城市名是必填,模型从用户问题里就能抽出来,没必要中途再问。

工具内征询适合决策依赖运行时结果的情况。订位满不满,只有查过才知道。这类决策无法提前放进参数模式。

多轮对话追问适合问题开放、需要来回解释的情况。但它的代价是模型夹在中间。用户说“随便哪天都行”,模型可能理解成“帮我选一天”,也可能理解成“取消”。征询把这段解释压缩成一个结构化表单,减少模型的自由发挥空间。

三种方案可以混用。工具参数负责模型能预判的部分,征询负责运行时才确定的部分,多轮对话负责征询也表达不了的开放问题。

超时、并发与状态管理

征询把一次工具调用变成了可能持续很久的挂起状态。这带来几个工程问题。

用户可能不在场。一个自动化流程调用工具,弹窗没人点,工具就永远挂着。服务端需要设置超时。超时后应该按 cancel 处理,让工具返回一个明确的“未获得输入”,而不是无限等待。超时时间取决于场景:交互式客户端可以等几分钟,后台任务可能只等几秒。

同一个会话里可能有多个征询。如果两个工具同时挂起,客户端要能区分它们。elicitation/create 的 id 字段承担这个职责,客户端的响应必须带上同一个 id。服务端在恢复执行时,要确保把答案还给正确的那个挂起调用。

工具挂起期间,会话状态可能变化。用户可能在等待期间关闭了客户端,或者网络断开。服务端应该把挂起的征询当作可取消的操作,在连接断开时清理相关状态,避免内存里堆积永远不会被回答的请求。

还有一个容易被忽略的点:征询的内容会进入对话历史。如果用户填了一个较长的备注,它会占用后续请求的上下文。对于频繁触发的征询,表单字段应该尽量精简。

需要观察哪些信号

征询上线后,有几类指标能反映它是否在正常工作。

触发率反映征询被发起的频率。如果某个工具几乎每次调用都触发征询,说明它本该把参数放进输入模式,或者模型没有拿到足够的信息。触发率过高会拖慢每一次调用。

接受率反映用户是否愿意配合。accept 占比低,可能是消息写得含糊,用户不知道为什么要填;也可能是表单字段太多,用户嫌麻烦。

取消率单独看。cancel 高而 decline 低,通常意味着用户没看懂或者误触,而不是真的拒绝。这时候应该检查客户端弹窗的措辞和按钮布局。

校验失败率反映客户端实现质量。服务端按 schema 校验失败的次数如果持续出现,说明某个客户端没有在提交前做本地校验,或者 schema 本身有歧义。

挂起时长反映用户响应速度。如果大量征询在超时边缘才被回答,说明超时设置偏紧,或者用户根本不在场,这类调用应该改用非交互路径。

这些指标最好按工具和服务端分别统计。一个服务端的所有工具都触发高取消率,问题可能在客户端的展示方式;只有某个工具触发,问题在它的消息和 schema 设计。

什么时候不该用征询

征询打断的是正在做事的人。它适合低频、高价值、无法预判的决策。

如果一个参数在 90% 的调用里都需要,把它放进工具输入模式,让模型一次填好。如果一个决策可以用默认值安全地处理,就不要问。比如删除一个空文件夹,直接删;只有文件夹非空时才确认。

如果用户可能不在场,征询要有超时和降级路径。降级可以是返回“需要人工确认”,也可以是走一个异步通知渠道,而不是让工具一直挂着。

如果答案需要嵌套结构,说明这个信息本该是工具的输入参数,而不是征询内容。受限 schema 不支持嵌套,这不是缺陷,是边界提示。

如果涉及凭据、令牌、支付信息,走 URL 模式,不要让这些数据经过客户端和模型上下文。

征询的设计还在演进。规范明确说明它可能在未来版本中变化。工程上应该把它当作一个需要版本兼容的能力:客户端在初始化时声明是否支持,服务端在发送前检查对方能力,不支持时回退到普通错误路径。这样即使协议调整,工具也不会因为一次征询失败而整体不可用。

资料来源

  1. Model Context Protocol Specification - Elicitation
  2. Model Context Protocol - Client Features: Elicitation
  3. Model Context Protocol GitHub Repository
  4. 征询 - MCP Python SDK