给 AI 客户端设计工具的 5 个反直觉原则:来自 91 个 MCP 工具的经验
为人类设计的 API 与为 AI agent 设计的工具是两套不同的设计范式。前者优化的是易读、易记、有 IDE 提示后者优化的是易选、易容错、给出来的信息够 LLM 继续推理。这两套原则在很多地方甚至相反。下面 5 条原则来自做 Funplay Unity MCP 半年的具体踩坑——一个跑在 Unity Editor 内部、把编辑器与 PlayMode 暴露为 91 个 MCP 工具的 server。每条原则在设计之初都被某种工程师本能反对过最终在 AI 客户端真实使用反馈里才确立。原则 1工具数量不是越多越好工程师的本能是职责单一、覆盖完整——每个高频操作单独定义一个工具文档化彻底、单元测试好写。但放到 LLM 视角下工具数量上升对选对率有显著负面影响。工具数量LLM 选错率LLM 选漏率token 开销新用户上手门槛实测在 90 工具规模下主流 LLM 在tools/list阶段就需要消耗大量 token 加载工具元数据且 tool selection 容易把get_component选成list_components、把set_property选成set_properties。做法把工具集分成两层。Funplay Unity MCP 默认暴露coreprofile 29 个高频工具fullprofile 91 个全集只在需要时切换。Core 集合的工具数控制在一个 LLM 上下文窗口能完整审视的量级30 以内是经验值。需要注意这条原则不等于少做工具。完整的 91 个工具该写还得写——只是 LLM 默认看到的应该是经过精选的子集。原则 2永远留一个万能逃生口如果做到了原则 1 的精简立即面临的问题是精选子集必然漏掉某些低频但合理的场景。例如调用项目里某个自定义 ScriptableObject 的静态方法这种需求无法预先定义专用工具。工程师本能的解决方案是再加几个通用辅助工具但这条路无限延伸。更好的做法是显式承认有漏洞并提供一个能在工具集之外执行任意逻辑的逃生口。Funplay Unity MCP 里这个逃生口是execute_code——AI 客户端提交 C# 代码片段Editor 内存编译执行publicclassCommandScript:IFunplayCommand{publicvoidExecute(ExecutionContextctx){// 任意 Unity Editor API 调用ctx.ReturnValue...;}}逃生口的存在使得工具集精简成为可承受的设计选择——客户端遇到工具集外的需求时不会卡死而是降级到execute_code拼装逻辑。适用范围任何提供宿主环境完整访问权的 MCP serverUnity Editor、浏览器、IDE、数据库 console都应考虑提供逃生口。逃生口的成本是AI 写错代码可能导致副作用收益是工具集可以保持精简且能覆盖未预期的场景。原则 3结构化返回胜过字符串返回最初的 MCP 工具实现里很多返回都是裸字符串“已创建对象 Cube”、“操作成功”。从工程师视角看这干净简洁从 AI 客户端视角看这是灾难——下一个调用想引用刚创建的对象得自己从字符串里 parse 出对象名且没有任何 ID。字符串返回 已创建对象 CubeAI 想引用只能按名称 find_by_name重名时拿到错对象操作失败 或副作用结构化返回 success/data/instanceIdAI 想引用直接 by_id精准且幂等做法所有工具返回统一为以下 JSON 模式{success:true,message:Created cube,data:{instanceId:13520,name:Cube,path:/Cube}}失败时换为{success:false,code:OBJECT_NOT_FOUND,error:GameObject Player not found in active scene,data:{searchedNames:[Player]}}code字段是机器可读的错误类型常量AI 客户端基于code分支判断处理逻辑比 free-formerror字符串可靠得多。原则 4用稳定 ID 形成调用链紧接着原则 3。返回了instanceId之后所有按对象操作的工具都应该接受find_methodby_id参数{tool:get_component_properties,arguments:{find_method:by_id,instance_id:13520,component_type:BoxCollider}}这种返回 ID → 后续 by_id 调用的设计让 AI 不需要在多个工具调用之间反复解析名字。一次调用产生 ID后续 N 次调用直接复用——既快又避免了重名/路径变动带来的歧义。set_component_propertyadd_componentcreate_primitiveAI 客户端set_component_propertyadd_componentcreate_primitiveAI 客户端create Cube{ instanceId: 13520 }by_id13520, typeRigidbody{ instanceId: 13521 }by_id13521, propmass, value5{ success: true }每一步都用前一步返回的instanceId没有任何字符串解析。Funplay Unity MCP 的所有写入类工具都接受find_methodby_id是这个原则的统一落地。Anti-pattern返回 ID 但不让其他工具消费 ID。例如某些 server 创建对象后只返回名字需要再list_objects拿 ID——这种设计强迫 AI 多走一轮且把ID 是否稳定的问题留给客户端。原则 5read-only 与 write 操作显式划分成熟的 MCP server 会区分两类工具read-only— 只读不改变宿主状态get_scene_info、list_componentswrite— 修改宿主状态set_component_property、execute_code工程师本能可能觉得调用方自己看名字判断就好——但 AI 客户端的看名字判断不可靠。同时宿主侧的安全/审计/权限策略需要在工具元数据层就能区分类型而不是逐工具特判。Funplay Unity MCP 用两个 attribute 显式标注[ToolProvider(Scene)]internalstaticclassSceneFunctions{[ReadOnlyTool][Description(Get info about the active scene)]publicstaticstringGetSceneInfo(){/* ... */}[SceneEditingTool][Description(Create a primitive GameObject in the active scene)]publicstaticstringCreatePrimitive(/* ... */){/* ... */}}[ReadOnlyTool]和[SceneEditingTool]这两个 attribute 不参与运行时逻辑纯粹是元数据。但它们让以下三件事变得可行用途实现Profile 过滤coreprofile 可以只暴露 read-only 工具给某些 AI 客户端审计日志write 工具的调用自动记入 Recent Activityread 不记工具描述自动在生成的 description 里加[Read-only]/[Modifies scene]标签把操作的副作用面建模成工具的一等元数据比依赖工具名约定可靠得多。MCP 协议层面的补充MCP 标准里的annotations.readOnlyHint/destructiveHint字段提供了协议级表达。如果你的 server 暴露给多个 AI 客户端使用应该填上这些 annotation让客户端 UI 能据此显示警告。原则之外一条经验性观察5 条原则的共通底层是一句话为 AI 设计工具时所有看起来给 AI 增加负担的设计都值得重新审视。工具描述 verbose 不是坏事——AI 不会嫌长它要的是消歧返回结构带冗余字段不是坏事——AI 会忽略它不需要的字段命名长一点find_game_objects_by_component_type不是坏事——AI 不会嫌打字累这些啰嗦的设计选择在人类视角下显得 over-engineered但对 LLM 工具选择 / 参数填写 / 后续推理都有显著正向。Funplay Unity MCP 的 91 个工具几乎全部走这条路名字偏长、描述偏多、返回字段偏全——而非偏简洁。写在最后这些原则没有任何一条是预先想出来的全都来自 AI 客户端的实际反馈原则 1 来自客户端在tools/list阶段消耗过多 token 的报告原则 2 来自频繁出现AI 想做某事但找不到对应工具的会话原则 3 来自客户端在多步操作中上一步返回的对象名找不到了的现象原则 4 来自 AI 客户端反复list_objects match by name浪费 round-trip原则 5 来自需要给某些场景做 dry-run / approval gate 时无法判别工具副作用把AI 怎么用当成第一公民去设计工具剩下的就是把这五条作为 checklist 不断回头审视。Funplay Unity MCP 的实现在 FunplayAI/funplay-unity-mcpMIT 协议。所有 91 个工具的 attribute 注册、结构化返回、instanceId 链路、core / full profile 划分都可以直接参考。