飞书 SDK 有 1228 个方法,我们只把其中 351 个做成了 AI 工具。这些工具没有写进 JSON 配置,也没有在运行期用反射扫出来,而是写成 351 个接口声明,由 Roslyn 源生成器在编译时产出工具描述、参数类、注册器和文档。下面讲这条生成链路怎么运转,以及为什么值得为它配 64 个契约测试。
0. 起因
把 1228 个方法全部暴露给模型,会同时撞上三个问题。
上下文装不下。现在 351 个工具的描述合计 47,495 个字符,已经需要精打细算;1228 个方法放到候选集里,模型选错的概率也会明显上升。
权限收不住。删除、转移、改权限这类方法会直接进入模型的可选范围。默认最小权限在“全都给”的形态下没法表达。
变更没人管。工具面的内容每天都在被模型读取,但没人能回答“昨天模型能看到什么,今天多了什么”。没有 diff 就没有评审。
所以我们关心的不是“怎么让模型调 API”,而是怎么把模型能看到的那部分系统做成一份能生成、能评审、能回滚的产物。
| 口径 | 数字 | 出处 |
|---|---|---|
| SDK 接口方法总数 | 1228 | FeishuToolMethodCatalog.SdkMethodCount |
| 已策展工具 | 351 | golden 快照 351 行(185 只读 + 166 写) |
| 业务域 | 34 | 工具名第一个 . 之前的部分 |
| 高危写工具 | 39 | 方法名命中危险词表 |
| 契约测试文件 | 64 | Tests/Mud.Feishu.AI.Tools.Tests/ContractGuards/ |
1. 五层结构
这条链路里,人只碰两处:写声明和写执行器,一共 4 个手改点。其余 6 个环节全部由生成器产出,包括注册器、参数类、Schema、契约表、DI 方法和快照内容。
| 环节 | 手写 / 生成 | 原因 |
|---|---|---|
| 工具声明 | 手写 | 选哪个能力、参数怎么切,是产品判断 |
| 执行器 | 手写 | 返回哪几个字段给模型,是逐字段的业务意图 |
| 域注册器 | 生成 | 纯机械聚合,手写必漏 |
| Args 参数类 | 生成 | 与声明同源,手写会漂移 |
| Schema | 生成 | 模型可见面的唯一来源 |
| 契约表 | 生成 | 漏字段直接在编译期报错 |
| DI Core 方法 | 生成 | 每个执行器一条注册链 |
| golden 快照 | 机械重固化 | 人只评审 diff,不手写内容 |
2. 工具写在接口上
2.1 一个声明的样子
[AgentTool("calendar.create_event",
Description = "在指定日历上创建一个日程(start/end 为 RFC3339 时间…)。写操作:需 calendar:calendar。",
RequiredScopes = ["calendar:calendar"],
IsWrite = true,
Source = nameof(IFeishuTenantV4CalendarEvent) + "." + nameof(IFeishuTenantV4CalendarEvent.CreateCalendarEventAsync))]
public interface IFeishuTenantCalendarCreateEventTool
{
Task<string> CreateEventAsync(
[ToolParameter("calendar_id", "日历 ID(形如 feishu.cn_xxx@group.calendar.feishu.cn)", Required = true)] string calendar_id,
[ToolParameter("summary", "日程标题", Required = true)] string summary,
[ToolParameter("start", "开始时间(RFC3339…)", Required = true)] string start,
[ToolParameter("end", "结束时间(RFC3339,须晚于 start)", Required = true)] string end,
[ToolParameter("idempotency_key", "幂等键(可选)…")] string? idempotency_key = null,
[ToolParameter("dry_run", "仅预演不创建(可选)…")] bool? dry_run = null,
CancellationToken cancellationToken = default);
}
看着只是给方法加了几个注解,但这几十行同时决定了四件事:模型看到什么描述、Schema 里有哪些字段、执行器拿到什么参数、白名单按什么身份过滤。下面分开说。
2.2 为什么用接口
接口里没有方法体,声明就只能是声明。执行器换 SDK 调用、改返回字段、重写错误处理,都不影响声明;反过来改一个参数描述,也不用动执行器。
还有一点是测试上的便利:生成器只读声明,不要求执行器存在。测试工程可以引用同一个剖面,单独生成一份自己的 Schema 去比对。
2.3 Source 用两段 nameof 拼
Source = nameof(IFeishuTenantV4CalendarEvent) + "." + nameof(IFeishuTenantV4CalendarEvent.CreateCalendarEventAsync)
拼出来的字符串是 "IFeishuTenantV4CalendarEvent.CreateCalendarEventAsync",和以前写字面量的结果一样。区别在于 SDK 方法改名时,IDE 的重命名会跟着改这里。生成器还会校验这个符号真实存在(MUDFT019),写错在编译期就断,不会等到运行期才发现工具调不通。
要注意一个语言细节:nameof(接口.方法) 只产出方法名,接口名会丢。必须写成两段再拼,否则 MUDFT019 直接报错。
2.4 参数平铺
参数都摊在方法签名上,不做嵌套对象。
| 声明 | 生成的 Schema |
|---|---|
[ToolParameter("calendar_id", …, Required = true)] string calendar_id | {"type":"string","description":"…"},进入 required |
[ToolParameter("limit", "…")] int? limit = null | {"type":"integer","description":"…"},不在 required 中 |
[ToolParameter("attendee_ids", …)] string[] attendee_ids | {"type":"array","items":{"type":"string"}} |
跨参数约束用 AnyOf 表达,比如“user_id 和 room_id 至少给一个”:
[AgentTool("calendar.find_free_slots", …, AnyOf = ["user_id|room_id"])]
生成到 Schema 里是 anyOf。这类约束写在声明上,模型在选参数时就能看到,不用等执行器拒绝一次再猜。
2.5 风险分级
IsWrite = true → 写类工具(必须走写白名单 + 审批标记)
方法名命中危险词表 → 高危写(high-risk-write)
WriteVerbKeywords = "delete|remove|transfer|cancel|revoke|resign|
permission|reset_secret|password|dismiss|purge|wipe"
判断依据是 SDK 方法名的 snake_case 子串,不是工具名。工具名可以起得人畜无害,方法名不会。这样加新工具时,不需要靠人记得去标风险等级。
2.6 为什么不直接扫全 SDK
| 做法 | 结果 |
|---|---|
反射扫描全部 IFeishu* 方法 | 得到 1228 个工具,等于没策展 |
| 运行期读 JSON 配置 | 配置和代码两套来源,改了调用不会改配置 |
| XML 注释驱动 | 注释不是契约,装不下 IsWrite / Source / Scopes |
3. 剖面:飞书的命名只出现在一个文件里
3.1 一个文件装下所有命名事实
生成器本身不认识飞书。它只认一份剖面,也就是一组描述“这个 SDK 长什么样”的字符串和正则。
[SdkToolProfile(
"Feishu",
// 1. 特性识别(扫描目标)
ToolAttributeName = "AgentTool",
ToolAttributeNamespace = "Mud.AI.Abstractions.Tools",
ToolHandlerAttributeName = "ToolHandler",
ToolHandlerAttributeNamespace = "Mud.AI.Abstractions.Tools.Handlers",
// 2. 源符号解析
SdkNamespaceRoot = "Mud.Feishu",
SoftAbsentNamespaceRoots = "Mud.AI",
InterfaceNameRegex = @"^IFeishu(?:Tenant|User)?V\d+(?<domain>[A-Z][a-zA-Z0-9]*)(?<resource>[A-Z][a-zA-Z0-9]*)?$",
// 3. 令牌身份推导
TokenKindStrategy = TokenKindDerivationStrategy.NamePrefix,
TokenKindMarkers = "IFeishuTenant=Tenant;IFeishuUser=User",
// 4. 产物前缀 / 诊断
ProductPrefix = "FeishuTool",
ProductPluralPrefix = "FeishuTools",
DiagnosticPrefix = "MUDFT",
WriteVerbKeywords = "delete|remove|transfer|cancel|revoke|resign|permission|…",
// 5. 产物落点
SchemaExtensionKey = "x-feishu",
OwnerAssembly = "Mud.Feishu.AI.Tools",
GeneratedNamespace = "Mud.Feishu.AI.Tools.Generated",
RiskEnumFullName = "Mud.Feishu.AI.AgentTools.FeishuToolRisk",
ResultTypeName = "ToolResult",
BindingTypeName = "FeishuToolBinding",
OutputWrapperTypeNames = "FeishuApiResult|FeishuApiPageListResult|…",
// 6. 资产目录
GuidanceDirectory = "/Guidance/",
AffordanceDirectory = "/Affordance/")]
internal sealed class FeishuToolProfile : ISdkToolProfile { }
3.2 这些槽位写错了会怎样
| 组 | 槽位 | 写错的后果 |
|---|---|---|
| 特性识别 | ToolAttributeName / ToolHandlerAttributeNamespace | 生成器零产出,构建仍然成功 |
| 源符号 | SdkNamespaceRoot / InterfaceNameRegex | 接口扫不到,工具静默变少 |
| 令牌身份 | TokenKindStrategy / TokenKindMarkers | 身份推导错,白名单放错类 |
| 产物落点 | GeneratedNamespace / RiskEnumFullName | CS0246 / CS0103 |
| 诊断 | DiagnosticPrefix / DiagnosticCategory | 诊断口径分叉,门禁失效 |
| 资产 | GuidanceDirectory / AffordanceDirectory | 资产收不到,golden 少一整个键 |
除了产物落点那几个会直接编译失败,其余多数是“构建通过但结果不对”。这种错最难查,所以 FeishuToolProfileContractGuards 用反射把每个槽位都钉住了。
3.3 身份从接口名推导
身份不写在 [AgentTool] 上。租户级接口和用户级接口本来就是 SDK 的事实,让工具作者再标一次只会标错。
推导结果会同时被白名单、令牌预检和执行链使用。只推一次,就不会出现“Schema 写 tenant、执行链按 user”的情况。名字里没有标记的接口(比如 IFeishuCalendarXxxTool)会落到默认 tenant,MUDFT016 会在编译期把这类不一致拦下来。
3.4 可选包没装,不算配置错误
SdkNamespaceRoot = "Mud.Feishu" // 硬依赖:找不到就是错
SoftAbsentNamespaceRoots = "Mud.AI" // 软依赖:缺席 ⇒ 该域工具不注册
比如知识检索器 Mud.AI.Abstractions.Knowledge.IRetriever 是可选的中立契约。宿主没装它,knowledge.* 工具就不进注册表,不报错,也不会留一个调用必失败的入口。等到白名单里真写了这些工具,装配期会明确报“未注册”。
不能图省事把 SdkNamespaceRoot 放宽成 "Mud"。它还负责按程序集名找 SDK,放宽后会去找一个叫 Mud 的程序集,结果是静默零产出。正确做法是加一个新槽位。
3.5 换一个 SDK 要改什么
4. 一次编译,产出九类文件
4.1 引擎以 Analyzer 包引入
<PackageReference Include="Mud.AI.Generator" Version="1.0.2"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<AdditionalFiles Include="FeishuToolSchemas.golden.txt"
Condition="Exists('FeishuToolSchemas.golden.txt') and '$(FeishuToolRefreeze)' != 'true'" />
<AdditionalFiles Include="Guidance\**\*.md" />
<AdditionalFiles Include="Affordance\**\*.md" />
<CompilerVisibleProperty Include="FeishuToolCatalog" />
<FeishuToolCatalog>true</FeishuToolCatalog>
ReferenceOutputAssembly="false" 表示它只是编译期工具,不进运行期依赖。golden、guidance、affordance 都通过 AdditionalFiles 传给引擎,编译期读取,运行期不做文件 IO。能力目录的开关走 CompilerVisibleProperty,没有为此新增配置节。
4.2 产物清单
| 产物 | 形态 | 谁在用 | 设计要点 |
|---|---|---|---|
FeishuToolSchemas | 工具名 → 完整信封 JSON | 工具桥、golden 门禁 | 模型可见面的唯一来源 |
FeishuToolNames | const string + All / WriteAll | 白名单、守卫、执行器 | 名字不会拼错 |
FeishuToolContracts | record FeishuToolContract(...) | 注册器、目录、守卫 | 运行期不解析 JSON |
FeishuToolMethodCatalog | 1228 条方法级目录 | feishu.schema_read | 含未策展方法 |
FeishuToolArgs | 每工具一个参数类 + Unpack() | 执行器 | 参数名只写一次 |
FeishuToolDomainRegistrars | 每执行器一个注册器 | DI | 手写注册器全部删掉 |
FeishuToolsServiceCollectionCoreExtensions | 每域一个 Add…Core() | 公开 DI 入口 | 装配不留手工清单 |
FeishuToolGuidance | 域全文 + 主题引用 | 指令面、MCP | 与工具面同一 pass |
FeishuToolCapabilityCatalog | 内部聚合事实 | 自检、测试 | internal,模型看不到 |
4.3 一个工具的完整链路
生成的参数类长这样:
internal sealed class CalendarCreateEventArgs
{
internal const string ToolName = """calendar.create_event""";
public string CalendarId { get; }
public string Summary { get; }
public string Start { get; }
public string End { get; }
public string? IdempotencyKey { get; }
public bool? DryRun { get; }
public static CalendarCreateEventArgs Unpack(IReadOnlyDictionary<string, object?> args)
{
var calendarId = ToolArgs.RequireString(args, """calendar_id""");
var summary = ToolArgs.RequireString(args, """summary""");
var start = ToolArgs.RequireString(args, """start""");
var end = ToolArgs.RequireString(args, """end""");
var idempotencyKey = ToolArgs.OptionalString(args, """idempotency_key""");
var dryRun = ToolArgs.OptionalBool(args, """dry_run""");
return new CalendarCreateEventArgs(calendarId, summary, start, end, idempotencyKey, dryRun);
}
}
执行器只做三件事:取参、调 SDK、挑要返回的字段。
[ToolHandler(typeof(IFeishuTenantCalendarCreateEventTool))]
public async Task<ToolResult> CreateEventAsync(
IReadOnlyDictionary<string, object?> arguments, CancellationToken ct)
{
var executor = new ToolExecutor(FeishuToolNames.CalendarCreateEvent, _maxResultLength);
return await executor.RunAsync(async () =>
{
var args = CalendarCreateEventArgs.Unpack(arguments);
var outcome = FeishuApiResultReader.Read(await _calendarEventClient.CreateCalendarEventAsync(…, ct));
return executor.FromApiUntruncated(outcome, data => new JsonObject { ["event_id"] = data.Event?.EventId });
});
}
这些产物都来自同一次语法树扫描,所以“Schema 有 dry_run、Args 没有”这类错在结构上不会发生。真要说一致性靠什么保证,靠的是同源,不是靠人记得同步。
5. 变更门禁
5.1 快照文件
FeishuToolSchemas.golden.txt 一共 351 行,每行是 工具名 \t 完整信封 JSON。单个信封展开后大致是这样:
{
"name": "calendar.add_event_attendees",
"description": "向指定日程添加与会者…写操作:需 calendar:calendar。",
"parameters": {
"type": "object",
"properties": {
"calendar_id": { "type": "string", "description": "…" },
"event_id": { "type": "string", "description": "…" },
"attendee_ids":{ "type": "array", "items": { "type": "string" }, "description": "…" }
},
"required": ["calendar_id", "event_id", "attendee_ids"]
},
"x-feishu": {
"risk": "write",
"is_write": true,
"identity": "tenant",
"required_scopes": ["calendar:calendar"],
"source": {
"sdk": "IFeishuTenantV4CalendarEvent.CreateCalendarEventAttendeeAsync",
"http": "POST",
"route": "/open-apis/calendar/v4/calendars/{calendar_id}/events/{event_id}/attendees"
},
"affordance": { "when": ["…"], "avoid_when": ["…"], "prerequisites": ["…"], "tips": ["…"] },
"output_schema": { "…": "…" }
}
}
x-feishu 是厂商扩展块,不放进参数 Schema 的关键字空间。MEAI 要求 JsonSchema 是纯参数 Schema,风险、身份、scope、SDK 锚点这些东西没有合适的位置,只能单独放一块。
5.2 改完之后会发生什么
用快照而不是断言几个字段,是因为断言只能防你想得到的变化。快照防的是字节级变化,并且把变化变成一行可以直接读的 diff。评审时看的是“模型那边的世界要不要这样变”,而不是“代码有没有改”。
5.3 诊断码
| 诊断 | 级别 | 拦什么 |
|---|---|---|
MUDFT014 | Error | golden 漂移 |
MUDFT016 | Error | 工具身份与承载接口名推导结果不一致 |
MUDFT019 | Error | Source 锚不到真实 SDK 符号 |
MUDFT026 | Error | 生成器内部异常的兜底诊断 |
MUDFT028 | Error | affordance 结构不合法(四节缺失或出现一级标题) |
MUDFT029 | Error | affordance 文件名对不上工具名(目录里放 README 也算) |
MUDFT030 | Error | affordance 里反引号引用的 域.工具 不存在 |
MUDFT031 | Warning | affordance 超预算(1024 字符 / 单节 320 字符) |
5.4 64 个契约测试
| 类别 | 代表 | 锁什么 |
|---|---|---|
| 规模与计数 | ToolSurfaceScaleContractGuards、ToolCountDocContractGuards | 工具数、域数、读写分布,以及文档里的数字 |
| 契约一致性 | FeishuToolContractGuards、FeishuToolDescriptorContractGuards | Schema 键 = Names = Contracts = 注册表 = 对照表 |
| 剖面 | FeishuToolProfileContractGuards | 逐槽反射锁值 |
| 资产 | AffordanceContractGuards、AffordanceCoverageContractGuards、GuidanceAssetContractGuards | 结构、覆盖率、引用存在性 |
| 导出 | SkillsExportContractTests、SkillsVersionConsistencyContractGuards | 产物逐字节一致、package_version 与包版本一致 |
| 接线 | DomainRegistrarTests、ToolDomainCoresWiringContractTests | 全域入口与逐域入口的工具集等价 |
| 执行器骨架 | ToolExecutorSkeletonGuards | 不许手写 if (!outcome.Ok) / catch 那套机械代码 |
| 引擎单一性 | SingleToolSurfaceEngineContractGuards | 仓库里不能有第二个 [Generator],不能双引用引擎 |
5.5 文档里的数字不能手抄
工具面从 365 收敛到 351 时,合并了 14 个能力重叠的工具,比如 mail.send_message 和 mail.send_draft 合成 mail.send。一次改动要同步的地方包括 golden 快照、全部 skills 产物、《工具权限对照表》v1.19 的 351 行,以及三份 README 里的规模数字。
这种事靠人记迟早会漏。现在数字由 ToolSurfaceScaleContractGuards 和 golden 快照机械比对,对不上就报红。
也正因为如此,文档里的历史数字和当前产物可能同时存在,比如某处还写着 1279,而编译期目录已经是 1228。这不妨碍阅读,但说明文档里的数字只能当说明,不能当断言依据。
5.6 快照重固化的一个坑
快照内容由测试从已经编译好的程序集里读。dotnet test 只在引用项目被判过期时才重建。新增一份 Affordance/*.md 经常不触发重建,于是生成器看不到新资产,固化程序把原样内容写回去,测试全绿,覆盖率却没动。
正确顺序是三步,第一步不能省:
# 1. 强制重跑生成器(-p:FeishuToolRefreeze=true 让构建期不比对快照)
dotnet build Mud.Feishu.AI.Tools/Mud.Feishu.AI.Tools.csproj -c Debug --no-incremental -p:FeishuToolRefreeze=true
# 2. 让测试用运行时真实 Schema 重写快照
$env:FeishuToolGoldenUpdate='true'
dotnet test Tests/Mud.Feishu.AI.Tools.Tests -p:FeishuToolRefreeze=true `
--filter "FullyQualifiedName~FeishuToolGoldenTests"
# 3. 评审 diff → 重导出 skills → 更新权限对照表 → 记 CHANGELOG
6. 运行期装配:注册不等于启用
6.1 三段式注册
public static IServiceCollection AddFeishuTools(this IServiceCollection services, Action<FeishuToolRegistry>? configure = null)
=> AddFeishuWriteToolCores(
AddFeishuReadonlyToolCores(
AddFeishuToolInfrastructure(services, configure)));
域入口如
AddFeishuCloudDocTools、AddFeishuImTools 是“域组”粒度。宿主只要其中一部分工具时,用白名单裁剪,不需要再把入口拆回一域一个。
6.2 两条链的同步问题
每个 AddFeishu{Tools}Core() 在全域入口和逐域入口都要登记一次。少登记一处,两条装配路径的工具集就不等价,DomainRegistrarTests 会直接把差异点出来。
单执行器混合域(Board、Attendance、Markdown、BitableDeep、SheetsDeep、TaskDeep、MailDeep)只能登记在一条链上。两条链都登记,域注册器就会被装配两次,FeishuToolRegistry 抛“工具已注册”。这个错在客户端缺席时看不见,宿主把客户端补齐之后整域一起炸。
读写混合域(mail、contact-department)的读工具跟着写链走。
6.3 注册表语义
| 操作 | 语义 |
|---|---|
Register(definition) | 收进注册表,不启用;重名抛异常 |
MapTool(name) | 白名单启用 |
IsEnabled(name) | 是否进入模型可见面 |
EnabledTools | 当前启用集,模型面、guidance、MCP 共用 |
AllTools | 全部已登记工具,守卫比对用 |
{
"FeishuAgent": {
"Tools": ["bitable.list_tables", "contact.search_user"], // 只读白名单
"WriteAllowList": ["im.send_message"], // 写工具单独键控,默认空
"MaxToolRisk": "high-risk-write",
"AllowedIdentities": ["tenant", "user"],
"ContentSafetyMode": "warn"
}
}
名单放错类别(写工具写进 Tools)在注册期就失败。启用了 identity=user 的工具却没放行 user,装配期直接报错并列出工具名,不会等到每次请求再拒绝一次,让人误以为是权限问题。
6.4 装饰顺序
var toolBridge = new FeishuToolAIFunction(definition, schemaJson, accessor);
var budgeted = new ToolBudgetAIFunction(toolBridge);
tools.Add(ApplyApprovalGate(budgeted, definition)); // IsWrite ? ApprovalRequiredAIFunction : 原样
审批标记必须放在最外层。MAF 的
FunctionInvokingChatClient 用 function is ApprovalRequiredAIFunction 判断“这次要不要先走人工审批”。如果把预算装饰器套在它外面,标记对 MAF 不可见,写工具的审批会静默失效,模型可以直接执行写操作。代价是预算耗尽时用户可能先被问一次审批,这个代价可以接受。
6.5 Schema 和描述怎么取出
_cachedParameterSchema = ToolSchemaJson.ExtractParameters(schemaJson); // 构造期解析一次
_description = _definition.Description + ToolAffordanceText.ForDescription(schemaJson);
信封是 OpenAI Functions 的形状,不是合法的 JSON Schema。ToolSchemaJson 是全包唯一做“信封转参数 Schema”的地方。直接拿信封当 Schema,参数约束会全部丢失,等于告诉模型“任意 JSON 都行”。
affordance 拼到 Description 里,是因为 x-feishu 不属于 Schema 关键字,模型读不到。没有资产时返回空串,描述保持逐字节不变。
7. 执行链
①②③ 的次序最容易被改坏,摘出来看一下:
// ① 上下文校验
if (string.IsNullOrWhiteSpace(context.AppKey))
return EgressResult(tool.Name, "工具执行上下文缺少 appKey——多租户隔离禁止默认应用兜底");
// ①' 入站净化:先于授权门禁(授权器可能把参数写审计,审计必须看到已净化形态)
var argumentFailure = ToolArgumentSanitizer.Validate(arguments);
if (argumentFailure is not null)
return await DenyAsync(…, ToolErrorSubtype.InvalidArgs, $"invalid_args: {argumentFailure}", …);
// ② 策略轴
var (policyFailure, policySubtype) = EvaluatePolicy(tool);
if (policyFailure is not null)
return await DenyAsync(…, ToolErrorCategory.Policy, policySubtype, $"policy_denied: {policyFailure}", …);
// ③ 授权门禁:拒绝时零调用下游、不切换租户上下文
var gate = await AuthorizeGateAsync(tool, arguments, context, cancellationToken);
if (!gate.Allowed)
return await DenyAsync(…, gate.Category, gate.Subtype, gate.Reason, …);
7.1 顺序为什么不能换
| 换法 | 后果 |
|---|---|
| 入站净化挪到授权之后 | 审计里的参数可能是未净化的形态(A5 不变量) |
| 租户切换挪到授权之前 | 被拒绝的调用也会切上下文,多一次令牌查找,也多一处泄漏面 |
| 出站净化挪到整形之后 | 宿主的整形钩子可能把敏感内容拼回结果(A4 不变量) |
| 内容安全挪到净化之后 | 脱敏后的文本参与规则匹配,命中率和审计口径都会偏 |
这些性质都用运行时行为断言锁,不扫源码。像“必须经过某一关”这种要求,扫源码只能证明有人写过,证明不了每次调用都走。
7.2 错误返回结构化结果
模型读到结构化错误,可以自己换参数或降级;抛异常会把整轮对话打断。
NormalizeUnstructuredFailure 还会把执行器返回的 [tool_error] 纯文本升级成结构化错误,免得旧执行器和新错误契约长期两种形态并存。
拒绝路径统一走 DenyAsync,同时投递审计,并带一个机器可读的 reason_code。
7.3 幂等与分页
有 idempotency_key 参数的工具,会把键透传成平台的 client_token;dry_run=true 时不下发请求,也不消耗键。
fetch_all=true 时交给 ToolPagination.AggregateOutcomesAsync 翻页聚合。让模型自己写循环不现实,每一页都是一次独立的下游调用,失败语义得统一。
28 个常用写工具里,10 个暴露了幂等键,18 个没暴露,其中 8 个天然幂等、10 个非幂等。这张表实际上就是“模型能不能安全重试”的说明书。
8. 导出:同一份契约的几种形态
8.1 四种出口
| 出口 | 形态 | 谁在用 | 与白名单的关系 |
|---|---|---|---|
| 进程内 Agent | IReadOnlyList<AIFunction> | MAF / MEAI | 只含已启用工具 |
MCP tools/list | JSON-RPC 信封 | 进程外 Agent | 只含已启用工具(名字 . 换成 _) |
| MCP Envelope 导出 | 静态全量清单 | CI、桥接器、差异比对 | 不受白名单影响,表达“能力存在” |
| Skills 导出 | skills/feishu-{domain}/SKILL.md + references | 仓外 Agent | 全量能力面 |
四种形态,数据源只有一份,都是编译期常量。
8.2 Guidance 的全文和摘要
摘要不再单独写一份资产,而是从同一份 L1 全文里取两个稳定片段:
【{domain}】{H1 标题}|{「路由优先级」首条} → 全文与深层避坑见 feishu.guidance_read(域, 主题)
单独写摘要,改全文时就会忘。进程内 Agent 和 MCP 两条链也要用同一种摘要,所以 GuidanceRoutingSummary 做成公开实现,两条链共用,各自的开关只决定用摘要还是全文。
8.3 Affordance
Mud.Feishu.AI.Tools/Affordance/{契约工具名}.md
## WHEN
## Avoid when
## Prerequisites
## Tips
四节都必须存在且非空,否则 MUDFT028。文件名要对上工具名,否则 MUDFT029。正文里反引号引用的 域.工具 会机械校验存在性,否则 MUDFT030。单份资产不超过 1024 字符,单节不超过 320 字符,超了是 MUDFT031,走基线拦增量,处理方式是压文案,不是抬预算。
覆盖率按风险分级。写面 100%,高危和写两级都要有资产。读面从 18% 起步,只覆盖高频的 list/get。当前合计 207 份,100,204 个字符。
读面没做满,是因为低频单点读取的选型问题更适合放在域 guidance 里讲,全部补资产只会把描述预算吃光。
8.4 Skills
IToolSchemaExporter.Export(ToolSchemaDialect.Skills);
// => {"dialect":"skills","files":[{"path":"skills/feishu-im/SKILL.md","content":"…"}, …]}
导出是纯函数,不做文件 IO。数据源全是编译期常量,落盘交给宿主、脚本或测试,这样库本身不用碰磁盘。
域、工具、参考文档都按 Ordinal 排序,同一份输入必然得到逐字节相同的产物,否则这类快照测试会随机变红。
SKILL.md 正文原样嵌入 L1 guidance,不再摘要或改写。加工一次就多一份来源。
产物里有两个版本号:version 是产物形态版本,结构变了才动;package_version 是包版本。宿主拿到的是一个目录树,看不到包元数据,没有 package_version 就不知道手上这份文件是不是当前包的。
8.5 描述长度
有一次工具面收敛顺手做了两件事:让相邻工具的首句互相指路(bitable 工作流、docx 三种粒度的读),删掉 174 处重复的写权限模板句。
描述总量从 54,891 字符降到 47,495。
判断标准是:执行链能强制的事实,不要写进描述让模型自觉遵守。描述只回答“什么时候用这个、不用那个、前置是什么”。
9. 没有策展的 877 个方法怎么办
| 层 | 内容 | 模型可见 |
|---|---|---|
| L1 能力目录 | 编译期聚合事实(方法总数、分组分布、策展计数) | 否,internal |
| L2 策展工具 | 351 个 [AgentTool] | 白名单启用后可见 |
L3 feishu.capability_lookup | 判断一个能力是不存在,还是存在但没策展 | 是 |
L3 feishu.schema_read | 查任意 SDK 方法的 HTTP、路由、参数、令牌、风险、策展状态 | 是,只读 |
L3 feishu.guidance_read | 按 {域}/{主题} 读 references | 是,只读 |
L3 feishu.tool_search | 在已策展工具里按语义检索 | 是,只读 |
9.1 为什么删掉 api_call
它曾经是一条“方法名加参数字典,动态调度 HTTP”的兜底通道。删掉有三个原因。
一是同一能力出现两条调用路径。策展工具和动态调度的投影、幂等、风险语义都不一样,维护时要在两处对齐。
二是风险最高。任意已登记方法都能被调度,相当于把整个 SDK 变成可调用面。
三是没人用。Demos 没启用,只有测试覆盖。
删掉之后,未策展能力对模型的语义从“可以兜底”变成“如实告知”。GuidanceToolReferenceContractGuards 会检查 guidance 里引用的 feishu.* 是否真实存在,防止同类东西再长回来。
9.2 候选名只用来提建议
CapabilityApplyLoop 把未策展方法按动词表、分页后缀、ById 尾巴投影成建议工具名,供人判断要不要策展。
它只产出建议,不产出通道。这条写在代码注释里,也是评审时会看的一条。
10. 几条工程上的做法
10.1 防止假绿
| 规则 | 机制 | 不这么做会怎样 |
|---|---|---|
标准构建必须 --no-incremental | 增量构建会跳过源生成器,诊断数恒为 0 | “0 诊断”是假的,产物其实没生成 |
| AOT 严格模式逐项目构建,同时断言 0 错误和 0 诊断 | 构建失败时诊断数也是 0 | 计数断言在构建失败时照样通过 |
测试断言 total > 0 | 死掉的 testhost 会写出空 TRX,退出码还是 1 | “测试通过”其实一条都没跑 |
三条都来自同一个教训:只断言计数、不校验前置状态,等于给自己发假证书。
10.2 一份数据只有一个出处
同一件事出现第二份写法时,先想想能不能删掉。这个仓库里多数时候答案是可以。
10.3 什么该报错,什么该跳过
| 场景 | 处理 | 原因 |
|---|---|---|
可选包未安装(Mud.AI) | 跳过,该域工具不注册 | 宿主有权不装 |
| 域客户端未注册 | 跳过,注册器不进注册表 | 白名单映射期才报未注册,错误面收敛 |
AppKey 缺失 | 报错 | 多租户隔离不允许默认应用兜底 |
| golden 漂移 | 报错,中断构建 | 模型可见面变更必须评审 |
Source 锚不到 | 报错 | 失效工具不能发布 |
标准很朴素:缺了它,运行时会不会出错,或者悄悄出错。会,就硬失败;不会,就跳过。
10.4 AOT
工具面几乎没有反射。Schema、契约、参数名都是 const string;JSON 序列化走 FeishuJsonAot 和源生成上下文;唯一一次 JsonDocument.Parse 发生在构造期,用来缓存参数 Schema 和 affordance,不在每次调用里做。
这是设计取向带来的顺带结果:能提前算出来的,就别留到运行时算。
10.5 契约测试怎么写
一个闭环只留一条等式。Schema 键 = Names.All = Contracts = 注册表 = 对照表 用一条断言表达,不拆成五条计数。
新守卫要能被触发。配不上反例的守卫只是装饰。
测试里 File.ReadAllText 读到的路径要在 FeishuDocs 登记。文档一搬家,没登记的守卫就会结构性变红。
“必须经过”这类性质用运行时行为断言,不扫源码。
11. 新增一个工具
11.1 六步
前四步是人做的,后两步是跑命令。真正容易出问题的是第四步:写执行器的时候顺手在别的地方也注册一遍,或者只改了全域入口。
11.2 完成标准
| # | 判据 |
|---|---|
| 1 | golden diff 已评审 |
| 2 | 新 scope 回填权威清单,权限对照表同批更新 |
| 3 | 有真实调用链路用例,断言 method、path、body、query 实参,而不只是“工具存在” |
| 4 | 有一条参数非法的结构化错误负例 |
| 5 | Source 能解析,MUDFT019 通过 |
| 6 | 写工具默认不启用,且过授权门禁 |
| 7 | risk / is_write / identity 与 Schema 一致 |
| 8 | 写类工具补 affordance 资产 |
| 9 | skills 产物逐字节一致,package_version 匹配 |
11.3 四个常见的坑
| 坑 | 现象 | 防线 |
|---|---|---|
Source 写成单段 nameof | 丢了接口名,工具锚不上 SDK | MUDFT019 |
| 接口名缺令牌标记 | 身份落到默认 tenant,user 工具被当租户工具 | MUDFT016 |
| 单执行器混合域在两条链都登记 | 报“工具已注册”,客户端补齐后才炸 | DomainRegistrarTests |
Affordance/ 目录里放 README | 整份目录被当资产,报文件名不匹配 | MUDFT029 |
12. 收尾
这套生成流程本身不复杂,真正花时间的是外围:快照、诊断、64 个契约测试,还有那些“构建通过了但结果是错的”的边角。生成器大概一周能写完,把“模型看到的东西变了必须有人知道”这件事做扎实,要长得多。
如果要从这里带走一点东西,大概是:模型能看到的那部分系统,值得当成一份需要评审的产物来对待,而不是运行时随手拼出来的字符串。