这是《Agent Harness 从用到会造》第 7 讲。
前面六讲,我们一直在做同一件事:把 Agent 从「能跑」推到「可控」。第 1 讲到第 3 讲拆了 harness 的骨架,第 4 讲到第 6 讲把工具调用、上下文管理、循环控制这几块拼了起来。到这一讲,你要开始往外伸手了——不是调用别人写好的工具,而是自己写一个 MCP server,把你的能力暴露给 Agent。
绝大多数人第一次写 MCP server,动作是一样的:打开文档,看 tools/list 怎么返回,看 tools/call 怎么处理,然后开始写功能。参数怎么解析,业务逻辑怎么调,结果怎么序列化回去。写完,本地测一遍 happy path,通了,收工。
这个顺序是错的。
写 server 不是写功能,是写「它会怎么失败」。你交付给 Agent 的不是一个函数,是一个在不确定环境里被反复调用的接口。Agent 不会像你本地测试那样,永远传对参数、永远等得起、永远拿到完整结果。它会传错类型,会在你还没返回时超时重试,会拿到一个被截断的 JSON 然后当成完整结果继续往下推理。功能决定这个 server 能做什么,失败模式决定这个 server 会不会把整条链路带沟里。
所以这一讲的核心判断只有一句:先把失败模式写出来,再写功能。
原理:失败模式先于功能设计
为什么顺序不能反
功能设计的隐含假设是「调用方是配合的」。你写 search(query: str) -> list,脑子里想的是调用方传一个正常的字符串,你返回一个正常的列表。这个假设在人类 API 里勉强成立,因为人类会读文档、会看报错、会改。
Agent 不读你的文档。它读的是 tools/list 返回的那段 schema 描述,然后基于当前上下文生成一次调用。它可能传 query: 123,可能传一个空字符串,可能传一个嵌套了三层的对象。它不是在「违反契约」,它是在按它理解的世界行动。你的 server 是它理解世界的一个边界,边界上发生的事,你得先想清楚。
失败模式先于功能设计,本质上是把「调用方可能怎么不配合」当成一等公民。你先列出这个 server 可能怎么坏,再决定功能边界画在哪。这样写出来的接口,schema 会更严,默认值会更保守,错误返回会更有信息量。
错误要能被调用方看懂
这是最容易被忽略的一条。很多人写 server,出错的时候 logger.error(...) 打一行日志,然后返回一个 {"error": "internal error"} 或者干脆抛异常让框架兜底。日志是给你自己看的,不是给调用方看的。
调用方是 Agent。Agent 看不到你的日志文件。它只能看到 tools/call 的返回内容。如果返回里只有一句「internal error」,Agent 能做的只有两件事:重试,或者放弃。它没法修正参数,没法换个策略,没法告诉你「我传错了」。
所以错误返回必须携带可操作的信息。至少要能回答三个问题:
- 是哪一类失败?(超时 / 参数不匹配 / 执行失败 / 静默截断)
- 失败发生在哪个环节?(参数校验 / 下游调用 / 结果序列化)
- 调用方能不能自己修?(能修的话,怎么修)
一个合格的错误返回长这样:
{
"isError": true,
"content": [
{
"type": "text",
"text": "PARAM_TYPE_MISMATCH: field 'query' expected string, got number(123). Retry with a string value."
}
]
}
注意这里没有堆栈,没有内部路径,没有你数据库的表名。它只暴露调用方需要的信息:错误类别、出错字段、期望类型、修正建议。内部细节留在你自己的日志里,那是另一条通道。
四类失败,按可发现性排序
把失败分类,不是为了好看,是因为不同类别的失败,被发现的难度差了一个数量级。按「调用方多容易发现」从易到难排:
第一类:参数不匹配。 最容易发现。调用方传错了类型、漏了必填字段、传了越界值。这类失败应该在参数校验层就被拦下,返回明确的 PARAM_* 错误。它甚至不该走到你的业务逻辑。
第二类:执行失败。 下游服务挂了、文件不存在、权限不够。这类失败通常有明确的异常信号,你能捕获,能返回 EXEC_* 错误。调用方看到就知道「这次没成,但原因清楚」。
第三类:超时。 麻烦开始出现。超时的本质是「你不知道对面发生了什么」。你发起了调用,等了 N 秒没回来,你返回超时。但对面可能还在跑,可能已经成功但响应丢了,可能真的卡死了。调用方拿到超时,第一反应是重试——而重试可能造成重复执行。超时是唯一一类「失败信号本身可能不准确」的错误。
第四类:静默截断。 最难查,因为它长得像成功。你返回了一个结果,调用方拿到了,格式合法,字段齐全,一切正常。但结果是不完整的——被截断了。可能是你的下游返回了 10 万条,你只取了前 1000 条;可能是响应体太大,序列化时被砍了一刀;可能是流式返回中途断了,你补了个合法的结尾。
静默截断的可怕之处在于:调用方会拿这个不完整的结果继续推理。它不知道少了东西,它会基于「这就是全部」做出判断。错误会一路传播下去,等到你发现的时候,已经离现场很远了。
这四类的排序,就是你的实现顺序。先写参数校验,再写执行错误处理,再写超时语义,最后——也是最花心思的——处理截断。
动手做:复现 boundary_lab 的失败模式,并补一种新失败
tools/boundary_lab/ 是一个专门用来复现失败模式的实验目录。它不是一个真实业务 server,而是一个「失败模式标本库」——每个失败模式一个最小可复现的例子,配一个拦截证据。
先看目录结构:
tools/boundary_lab/
├── README.md
├── server.py # MCP server 主体,暴露若干工具
├── failures/
│ ├── param_mismatch.py
│ ├── exec_failure.py
│ ├── timeout.py
│ └── silent_truncation.py
└── evidence/ # 每种失败的拦截证据落这里
环节一:跑起来,看它怎么坏
先装依赖、起 server。这里用最朴素的 stdio 方式,方便你直接观察输入输出:
cd tools/boundary_lab
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"
python server.py --transport stdio
server 起来之后,用 MCP 的 CLI 客户端连上去,列出工具:
mcp call --server "python server.py --transport stdio" tools/list
你会看到 boundary_lab 暴露了四个工具,分别对应四类失败。每个工具的名字就说明了它会怎么坏,比如 fail_param_mismatch、fail_exec_failure、fail_timeout、fail_silent_truncation。
环节二:逐个复现,收集拦截证据
复现的关键不是「看到它报错」,而是拿到拦截证据。每种失败都要有对应的证据:返回码、日志行、或者超时计时。证据落到 evidence/ 下,一个失败一个文件。
先复现参数不匹配:
mcp call --server "python server.py --transport stdio" \
tools/call fail_param_mismatch '{"query": 123}' \
| tee evidence/param_mismatch.json
期望看到的是 isError: true,错误文本里带 PARAM_TYPE_MISMATCH,并且明确指出 query 期望 string、收到 number。如果返回里只有一句泛泛的「invalid input」,说明你的参数校验层没把信息透出来,回去改。
再复现执行失败:
mcp call --server "python server.py --transport stdio" \
tools/call fail_exec_failure '{"path": "/nonexistent/file"}' \
| tee evidence/exec_failure.json
这里期望看到 EXEC_NOT_FOUND 之类的明确类别,而不是把 FileNotFoundError 的堆栈直接吐出来。
超时这一类,需要你观察计时。fail_timeout 工具内部会 sleep 超过客户端超时阈值,你要在客户端侧记录「发起时刻」和「收到超时时刻」:
mcp call --server "python server.py --transport stdio" \
--timeout 2 \
tools/call fail_timeout '{"seconds": 10}' \
| tee evidence/timeout.json
关键证据是:客户端在 2 秒左右返回超时,但 server 侧的日志显示那次调用还在跑。这个「客户端已超时、服务端仍在执行」的错位,就是超时类失败的核心特征。把两边的日志都存下来。
环节三:静默截断——重点复现
前三类失败,你大概率能一次跑通。静默截断不一样,它需要你故意制造一个看起来成功的结果。
fail_silent_truncation 工具的设计是:接收一个 count 参数,内部生成 count 条记录,但序列化时只保留前 limit 条,且不告诉调用方被截断了。调用方拿到的是一个合法的、字段齐全的、长度是 limit 的列表。
mcp call --server "python server.py --transport stdio" \
tools/call fail_silent_truncation '{"count": 10000, "limit": 100}' \
| tee evidence/silent_truncation.json
跑完你会看到:返回 isError: false,内容是一个 100 条的列表,格式完全合法。没有任何地方告诉你原本有 10000 条。 这就是静默截断。
拦截它的证据不是「报错」,而是「对不上」。你需要在客户端侧独立计算期望条数,然后和实际收到的条数比对。这个比对逻辑,就是拦截静默截断的唯一手段——永远不要相信一个没有显式完整性标记的结果。
环节四:补一种新失败
现在轮到你了。boundary_lab 里现成的四类失败,覆盖了「参数、执行、超时、截断」。但真实世界里还有一类失败,它不属于这四类中的任何一类,却经常出现:部分成功。
部分成功的意思是:一次调用处理了 N 个对象,其中 M 个成功、N-M 个失败,但返回时只报告了成功的那部分,失败的部分被静默丢弃。它和静默截断的区别是:截断是「结果被砍短了」,部分成功是「结果里混着失败,但失败被藏起来了」。
你要在 failures/ 下新增一个 partial_success.py,实现一个工具 fail_partial_success,接收一个 items 列表,对其中一部分故意失败,返回时只包含成功的项,且不标注哪些失败了。
写完之后,用同样的方式收集证据:
mcp call --server "python server.py --transport stdio" \
tools/call fail_partial_success '{"items": ["a", "b", "c", "d"]}' \
| tee evidence/partial_success.json
然后回答一个问题:调用方拿到这个返回,能不能知道 b 和 d 失败了?如果不能,你的拦截证据应该是什么?
正确的做法是:返回里必须带一个显式的失败清单,哪怕它是空的。一个合格的返回长这样:
{
"isError": false,
"content": [
{
"type": "text",
"text": "{\"succeeded\": [\"a\", \"c\"], \"failed\": [{\"item\": \"b\", \"reason\": \"EXEC_REJECTED\"}, {\"item\": \"d\", \"reason\": \"EXEC_REJECTED\"}], \"total\": 4, \"complete\": true}"
}
]
}
注意 complete: true 这个字段。它是完整性标记——调用方看到它,才知道这个结果没有被截断、没有隐藏失败。所有可能返回不完整结果的工具,都应该带这个标记。这是拦截静默截断和部分成功的通用手段。
环节五:验证每种失败都有拦截证据
最后一步是收口。回到 evidence/ 目录,逐个检查:
ls -1 evidence/
# param_mismatch.json
# exec_failure.json
# timeout.json
# silent_truncation.json
# partial_success.json
每种失败都要能回答三个问题:
- 返回码是什么? 是
isError: true还是isError: false?如果是false,那这个失败就是靠完整性标记暴露的。 - 日志里有什么? server 侧日志要能定位到具体环节,客户端侧日志要能记录发起和收到的时间。
- 超时的话,两边时间对得上吗? 客户端超时时刻和服务端实际完成时刻,必须都留痕。
如果某个失败你拿不出证据,说明它还没被真正拦截——它只是「碰巧没发生」。
把失败模式写进 schema
复现完这五类失败,回到你的 server 设计。你会发现一个变化:schema 不再是「功能描述」,而是「失败契约」。
举几个具体的改法:
必填字段显式标注,不给默认值。 如果 query 是必填的,就不要给它 default: ""。默认值会让调用方以为可以省略,省略之后你拿到空字符串,又得在业务逻辑里判断——判断逻辑散在各处,就是失败模式的温床。让 schema 拦住它。
数值字段带上下界。 count 如果是 1 到 1000,就在 schema 里写 minimum: 1, maximum: 1000。调用方传 10000,在参数校验层就被拦下,返回 PARAM_OUT_OF_RANGE,而不是让它走到下游再爆。
可能返回大量数据的工具,schema 里加一个 limit 参数,并且返回里带 complete 标记。 这是把「截断」从隐式变成显式。调用方要么接受一个明确标记为不完整的结果,要么自己调大 limit。
所有可能耗时的工具,schema 里说明超时语义。 是「超时即取消」还是「超时后仍在后台执行」?这两种语义对调用方的影响完全不同。前者可以安全重试,后者重试会重复执行。这个区别必须写进工具描述,让 Agent 在生成调用时就能看到。
会推翻这一讲的现象
这一讲的核心判断是「失败模式先于功能设计」。如果出现下面这些现象,说明这个判断在你的场景里需要修正:
现象一:你的 server 只被一个确定性调用方使用。 如果调用方是你自己写的、参数永远正确的代码,那失败模式的价值会大幅下降——因为失败根本不会发生。这种情况下,先写功能是合理的。但要注意:一旦这个 server 暴露给 Agent,假设立刻失效。
现象二:失败模式的数量爆炸到无法穷举。 有些 server 的下游极其复杂,失败模式可能有几十种。这时候「先写失败模式」会变成「先写一个永远写不完的清单」。正确的做法是按类别收敛——把几十种具体失败归到四五个类别里,每类给一个统一的处理策略,而不是逐个枚举。
现象三:超时语义由框架统一处理,server 无法干预。 如果你的 MCP 框架在传输层就统一处理了超时,server 拿不到超时事件,那「超时」这一类失败就不该由 server 负责。这时候你要做的是确认框架的超时语义,然后在工具描述里如实转述,而不是自己再实现一套。
课后思考题
第一题:你写的 server 里,哪一种失败是调用方永远看不到的?
思路:从「调用方能看到什么」倒推。调用方只能看到 tools/call 的返回内容。那么,任何没有进入返回内容的失败,调用方都看不到。具体来说:
- 被
try/except吞掉、只打了日志没改返回的异常; - 被默认值掩盖的参数缺失(调用方以为传了,其实用了默认值);
- 被截断但没标记的结果;
- 部分成功里被丢弃的失败项。
这四类里,前三类你这一讲已经处理了。第四类——部分成功——是最隐蔽的,因为它需要你在业务逻辑层主动记录失败项,而不是在错误处理层。很多 server 的错误处理很完善,但业务逻辑里一个 for 循环 continue 掉失败项,调用方就永远看不到了。
第二题:超时和「还在跑」在接口上怎么区分?
思路:这是超时语义的核心问题。从接口层面看,有三种可能的语义:
- 超时即取消:客户端超时后,服务端收到取消信号,停止执行。调用方可以安全重试。
- 超时但仍在跑:客户端超时,服务端继续执行。调用方重试会导致重复执行。
- 异步任务:调用立即返回一个任务 ID,调用方轮询任务状态。超时只发生在轮询上,不影响任务本身。
区分它们的关键是:接口有没有返回一个可查询的状态句柄。语义 1 和 2 都没有句柄,调用方无法知道对面到底停没停。语义 3 有句柄,调用方可以查询「还在跑」还是「已完成」。
所以,如果你的工具可能耗时较长,正确的设计是语义 3——立即返回任务 ID,把「超时」和「还在跑」这两个状态从接口上分开。这也是为什么很多生产级 MCP server 的长任务工具,返回的不是结果,而是一个 task_id。
这一讲你做的事,本质上是把「失败」从一个事后处理的问题,变成一个事前设计的问题。boundary_lab 里的五类失败,是你以后写任何 server 都要过一遍的检查表。参数、执行、超时、截断、部分成功——每一类都问一遍「我的 server 会怎么坏」,然后为每一种坏法准备一个拦截证据。
下一讲,我们把这套失败模式接到 harness 的循环里,看 Agent 拿到这些错误返回之后,怎么决定重试、换参数、还是放弃。