为什么想做一个能干活的东西
飞书上的 AI demo,大多长一个样:收到消息、拼一段 prompt、调一次聊天接口、把结果发回去。演示的时候很顺,可一旦你想让它真的替人做事------查某个人下周有没有空、把约好的会挪个时间、把纪要整理成文档------这套结构立刻不够用。它记不住上一轮说过什么,也调不动飞书任何一个接口,模型能给的只有一句「请手动操作」。
所以「能干活」是条分界线。跨过去之后你会发现,缺的其实不是模型,是一整套运行时:会话怎么存、工具怎么调度、调用链怎么观测、出错了怎么查、写操作怎么拦。这些事单拎出来都不难,凑在一起就是一堆容易写错的胶水代码。
我们的选择是把通用部分交给 Microsoft Agent Framework(下面统一叫 MAF),飞书特有的部分留在 SDK 里。这篇就把这套东西拆开讲:架构怎么分层、工具怎么管住、会话和流式怎么接,以及几个到现在想起来还后背发凉的坑。目标倒不复杂------让机器人在飞书里像个能派活的同事,而不是只会应答的客服。
它应该比 demo 多些什么
口说无凭,先列表。
| 能力维度 | 套壳机器人 | 工作搭子 |
|---|---|---|
| 对话记忆 | 每轮失忆 | 会话持久化,隔天还记得上周的约定 |
| 干活 | 「抱歉,我无法操作日历」 | 351 个策展工具,读写分治 |
| 写操作安全 | 无 | 白名单 + 风险分级 + 人工审批,三道闸 |
| 多租户 | 无概念 | 令牌隔离,宁可失败也不跨租户兜底 |
| 排查手段 | 全靠猜 | OTel 全链路,每个回合有 Span |
| 流式回复 | 整段蹦出来 | 卡片打字机,降级自动切分片编辑 |
右边这一列没有一行是「调一次聊天接口」能解决的。这也是为什么我们最终把主循环交给 MAF------这些能力里的通用部分(Agent 循环、Function Call、状态袋、审批配对)框架已经写好了,剩下的飞书特有部分才是 SDK 该干的活。
飞书官方有 CLI 了,为什么还自己写
写这篇之前,我把 larksuite/cli 又拉下来玩了一遍。2026 年 3 月底开源的,Go 写的、npm 分发,npx 一条命令装完,200+ 精选命令、2500+ Raw API 全量兜底、26 个 Agent Skills 开箱即用,OAuth 登录还能用你个人的飞书身份。说实话做得挺好,好到我有一瞬间怀疑这活是不是白干了。
后来想明白了:它俩不是一回事,解决的是两种场景。
CLI 是「终端里的 Agent 去操作飞书」。人在 Claude Code、Cursor 里说一句话,Agent 拼一条命令 spawn 出去,跑完把 JSON 拿回来。身份是 OAuth 登录的 user 或 bot,凭证放系统钥匙串,形态天然贴着个人工作流。三分钟装完就能干活,这点确实服气。
我们要的是「飞书里的 Agent,跑在服务端、嵌进自己的产品」。一个进程托管多个企业应用,用户消息从 WebSocket 进来,Agent 在进程内跑一轮、中间调十次工具、最后流式回复回去,每一步都要过执行链、要审计、要能挂审批。
拐点落在三个地方,而且每一条都是 CLI 的形态决定的,不是它做得不好:
进程边界。 CLI 每次调用都是 spawn 一个进程、跑一条命令、退出。单次工具调用几十毫秒开销,看着不算什么,但一个回合里工具调用是连串的(查忙闲 → 查日程 → 改日程),还要复用 HTTP 连接池、令牌缓存、租户作用域,这些东西全在进程边界外面,拿不回来。服务端每轮对话反复起进程,吞吐和延迟都不划算。
身份模型。 CLI 的卖点是 OAuth 交互登录,人坐在终端前点一下授权。服务端多租户场景里没有「人在终端前」这回事,凭证必须来自应用配置(App ID / Secret),令牌由 SDK 托管、按租户隔离。让每个租户挂一个交互式登录会话,运维能疯。
治理深度。 CLI 有输入注入防护、输出净化、钥匙串存储,这些都很对。但它没有「写工具默认全关、逐个列名启用、高危动作必须人工点头、审批响应配对校验」这一层。企业里让 Agent 改日程、批审批,这一层不是可选项。
再加两条小的:工具契约在 CLI 那边是 JSON 文本,模型看到的就是字符串;我们这边每个工具都是编译期 Schema、source 三元组跟 SDK 交叉校验、AOT 零反射。可观测性同理------进程外调用进不了同一个 Trace,工具调用没有 Span 归属,出问题只能翻日志。
所以结论不是「CLI 不行」,而是分工不同。下面这张图里左边那条分支,我会毫不犹豫让你直接用 CLI:
#mermaid-svg-aVmLHGCqiaZyGDLY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aVmLHGCqiaZyGDLY .error-icon{fill:#552222;}#mermaid-svg-aVmLHGCqiaZyGDLY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aVmLHGCqiaZyGDLY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aVmLHGCqiaZyGDLY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aVmLHGCqiaZyGDLY .marker.cross{stroke:#333333;}#mermaid-svg-aVmLHGCqiaZyGDLY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aVmLHGCqiaZyGDLY p{margin:0;}#mermaid-svg-aVmLHGCqiaZyGDLY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY .cluster-label text{fill:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY .cluster-label span{color:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY .cluster-label span p{background-color:transparent;}#mermaid-svg-aVmLHGCqiaZyGDLY .label text,#mermaid-svg-aVmLHGCqiaZyGDLY span{fill:#333;color:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY .node rect,#mermaid-svg-aVmLHGCqiaZyGDLY .node circle,#mermaid-svg-aVmLHGCqiaZyGDLY .node ellipse,#mermaid-svg-aVmLHGCqiaZyGDLY .node polygon,#mermaid-svg-aVmLHGCqiaZyGDLY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aVmLHGCqiaZyGDLY .rough-node .label text,#mermaid-svg-aVmLHGCqiaZyGDLY .node .label text,#mermaid-svg-aVmLHGCqiaZyGDLY .image-shape .label,#mermaid-svg-aVmLHGCqiaZyGDLY .icon-shape .label{text-anchor:middle;}#mermaid-svg-aVmLHGCqiaZyGDLY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aVmLHGCqiaZyGDLY .rough-node .label,#mermaid-svg-aVmLHGCqiaZyGDLY .node .label,#mermaid-svg-aVmLHGCqiaZyGDLY .image-shape .label,#mermaid-svg-aVmLHGCqiaZyGDLY .icon-shape .label{text-align:center;}#mermaid-svg-aVmLHGCqiaZyGDLY .node.clickable{cursor:pointer;}#mermaid-svg-aVmLHGCqiaZyGDLY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aVmLHGCqiaZyGDLY .arrowheadPath{fill:#333333;}#mermaid-svg-aVmLHGCqiaZyGDLY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aVmLHGCqiaZyGDLY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aVmLHGCqiaZyGDLY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aVmLHGCqiaZyGDLY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aVmLHGCqiaZyGDLY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aVmLHGCqiaZyGDLY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aVmLHGCqiaZyGDLY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aVmLHGCqiaZyGDLY .cluster text{fill:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY .cluster span{color:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aVmLHGCqiaZyGDLY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aVmLHGCqiaZyGDLY rect.text{fill:none;stroke-width:0;}#mermaid-svg-aVmLHGCqiaZyGDLY .icon-shape,#mermaid-svg-aVmLHGCqiaZyGDLY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aVmLHGCqiaZyGDLY .icon-shape p,#mermaid-svg-aVmLHGCqiaZyGDLY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aVmLHGCqiaZyGDLY .icon-shape .label rect,#mermaid-svg-aVmLHGCqiaZyGDLY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aVmLHGCqiaZyGDLY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aVmLHGCqiaZyGDLY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aVmLHGCqiaZyGDLY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 终端 / Claude Code / Cursor
服务端 / 多租户 / 自有产品
Agent 跑在哪里?
飞书官方 CLI
OAuth 双身份 · 200+ 精选命令
本 SDK 进程内集成
七步执行链 · HITL 审批 · 编译期 Schema
npx 装完即用
2500+ Raw API 兜底
同一 Trace
写工具默认全关
逐条摊开是这么个对照:
| 维度 | 飞书官方 CLI | 本 SDK(进程内) |
|---|---|---|
| 形态 | 终端命令,每次调用一个进程 | 进程内库,随宿主应用走 |
| 身份 | OAuth 交互登录,user / bot 双身份 | App ID + Secret,令牌托管、多租户隔离 |
| 覆盖 | 200+ 精选命令,2500+ Raw API 兜底 | 351 个策展工具 + 1268 个强类型方法 |
| 治理 | 注入防护、输出净化、钥匙串 | 七步执行链 + 写工具白名单 + HITL 审批 |
| 契约 | JSON 文本输出 | 编译期 Schema,source 三元组交叉校验 |
| 观测 | 进程外,独立日志 | 同一 Trace,工具调用有 Span |
| 适合 | 个人终端、Claude Code / Cursor 等现成 Agent | 服务端、多租户、嵌进自有产品 |
说白了,如果你的团队就在 Claude Code 里干活,官方 CLI 甚至比接 MCP 更省事,别为了「统一技术栈」绕远路。而只要 Agent 得跑在自己服务里、要对每个租户负责,那 CLI 这层壳补不上我们要的那几块,得自己长在进程里。
分层:通用的交给 MAF,产品形态留在 SDK
先看整体。核心是一个三层委托链:
#mermaid-svg-meKLg3j0VW0162XZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-meKLg3j0VW0162XZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-meKLg3j0VW0162XZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-meKLg3j0VW0162XZ .error-icon{fill:#552222;}#mermaid-svg-meKLg3j0VW0162XZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-meKLg3j0VW0162XZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-meKLg3j0VW0162XZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-meKLg3j0VW0162XZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-meKLg3j0VW0162XZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-meKLg3j0VW0162XZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-meKLg3j0VW0162XZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-meKLg3j0VW0162XZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-meKLg3j0VW0162XZ .marker.cross{stroke:#333333;}#mermaid-svg-meKLg3j0VW0162XZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-meKLg3j0VW0162XZ p{margin:0;}#mermaid-svg-meKLg3j0VW0162XZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-meKLg3j0VW0162XZ .cluster-label text{fill:#333;}#mermaid-svg-meKLg3j0VW0162XZ .cluster-label span{color:#333;}#mermaid-svg-meKLg3j0VW0162XZ .cluster-label span p{background-color:transparent;}#mermaid-svg-meKLg3j0VW0162XZ .label text,#mermaid-svg-meKLg3j0VW0162XZ span{fill:#333;color:#333;}#mermaid-svg-meKLg3j0VW0162XZ .node rect,#mermaid-svg-meKLg3j0VW0162XZ .node circle,#mermaid-svg-meKLg3j0VW0162XZ .node ellipse,#mermaid-svg-meKLg3j0VW0162XZ .node polygon,#mermaid-svg-meKLg3j0VW0162XZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-meKLg3j0VW0162XZ .rough-node .label text,#mermaid-svg-meKLg3j0VW0162XZ .node .label text,#mermaid-svg-meKLg3j0VW0162XZ .image-shape .label,#mermaid-svg-meKLg3j0VW0162XZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-meKLg3j0VW0162XZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-meKLg3j0VW0162XZ .rough-node .label,#mermaid-svg-meKLg3j0VW0162XZ .node .label,#mermaid-svg-meKLg3j0VW0162XZ .image-shape .label,#mermaid-svg-meKLg3j0VW0162XZ .icon-shape .label{text-align:center;}#mermaid-svg-meKLg3j0VW0162XZ .node.clickable{cursor:pointer;}#mermaid-svg-meKLg3j0VW0162XZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-meKLg3j0VW0162XZ .arrowheadPath{fill:#333333;}#mermaid-svg-meKLg3j0VW0162XZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-meKLg3j0VW0162XZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-meKLg3j0VW0162XZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-meKLg3j0VW0162XZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-meKLg3j0VW0162XZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-meKLg3j0VW0162XZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-meKLg3j0VW0162XZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-meKLg3j0VW0162XZ .cluster text{fill:#333;}#mermaid-svg-meKLg3j0VW0162XZ .cluster span{color:#333;}#mermaid-svg-meKLg3j0VW0162XZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-meKLg3j0VW0162XZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-meKLg3j0VW0162XZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-meKLg3j0VW0162XZ .icon-shape,#mermaid-svg-meKLg3j0VW0162XZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-meKLg3j0VW0162XZ .icon-shape p,#mermaid-svg-meKLg3j0VW0162XZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-meKLg3j0VW0162XZ .icon-shape .label rect,#mermaid-svg-meKLg3j0VW0162XZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-meKLg3j0VW0162XZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-meKLg3j0VW0162XZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-meKLg3j0VW0162XZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 委托
委托
Id / Name / GetService / 会话钩子
全部由 MAF 基类提供,产品层不手写
FeishuAgent(产品层)
会话解析 · 选项装配 · 飞书遥测
ObservedDelegatingAgent(运行期编织)
摘要先于 Run · Span · 耗时 · token 用量
ChatClientAgent(MAF 内层)
只做一件事:模型 → 生成
IChatClient(键控服务)
任意 OpenAI-compatible 端点
OpenAI / 智谱 / 私有化 vLLM
MAF 基类
这个分法背后的立场很简单:MAF 管「任意 Agent 都要面对的事」,飞书 SDK 管「飞书这个产品特有的事」。哪些工具、什么权限、时间用什么格式、消息走哪条通道------这些产品事实一行都不进 MAF 的层。好处是实打实的:MAF 升版本的时候,产品层不用跟着地震。
有个细节单独说,因为它是那种「编译不报错、跑起来也没异常、只有监控里少了一截 Span」的隐形坑:FeishuAgent 手下一个 Id、Name、GetService 都没写,全部继承自 MAF 的 DelegatingAIAgent。不是偷懒。GetService 的正确语义是「先判自身、再委托内层」------要是自己手写时漏了先判自身,宿主拿 AIAgent 引用调下去会直接命中内层的 ChatClientAgent,你在外面织的遥测和摘要瞬间形同虚设。这种坑踩进去半天都爬不出来,让基类管这摊事,是省心也是自保。
装配:五行代码
空讲架构没意思,直接看代码:
csharp
builder.Services.AddFeishuApp(builder.Configuration); // ① 飞书应用 + 令牌托管
builder.Services.CreateFeishuServicesBuilder() // ② 全量 API 接口
.AddAllApis().Build();
builder.Services.AddFeishuTools(); // ③ 工具注册表(注意:注册 ≠ 启用)
builder.Services.AddFeishuOpenAIChatClient( // ④ 模型客户端
"main", "glm-4-flash", apiKey,
"https://open.bigmodel.cn/api/paas/v4");
builder.Services.AddFeishuAgent(builder.Configuration) // ⑤ Agent 本体 + 集成面
.AddFeishuStreamingChannel()
.AddFeishuImConversationHandler();
五行各自的分工和几个容易被忽略的点,一张表说清:
| 步骤 | 干什么 | 值得多看一眼的地方 |
|---|---|---|
① AddFeishuApp |
应用注册 + 令牌全生命周期 | 获取/缓存/刷新/失效级联全自动,Agent 层从头到尾不碰 token |
② AddAllApis() |
挂全量 API 接口 | 工具的下游就是这些接口 |
③ AddFeishuTools() |
351 个策展工具进容器 | 「注册」不等于「启用」,给模型哪些由配置决定 |
④ AddFeishuOpenAIChatClient |
键控模型客户端 | 端点强制 HTTPS(环回例外);解析不到装配期就炸,不透传 null |
⑤ AddFeishuAgent |
Agent + 会话 + 通道 | 配置启动期校验,ValidateOnStart,配错拒启 |
第 ④ 步的 HTTPS 白名单是我特别喜欢的一个细节。我见过不止一个项目把模型端点配成 http 内网地址,用户对话明文飞出去。这种安全默认就该焊死在 SDK 层,不给配置留口子。
配置长这样。注意 WriteAllowList,写工具默认一个都不给:
json
{
"FeishuApps": [{
"AppKey": "default", "AppId": "cli_xxx",
"AppSecret": "xxx", "IsDefault": true
}],
"FeishuAgent": {
"Tools": ["bitable.list_tables", "contact.search_user", "docx.get_document"],
"WriteAllowList": ["calendar.update_event"],
"MaxToolRisk": "high-risk-write"
}
}
然后业务代码里用它,就没什么可说的了------注入、调用,完事:
csharp
public class ChatController(IFeishuAgent agent) : ControllerBase
{
[HttpPost("chat")]
public async Task<IActionResult> Chat([FromBody] string userMessage, CancellationToken ct)
{
var response = await agent.RunAsync(userMessage, ct);
return Ok(response.Text);
}
}
工具:351 个,默认只开只读
工具面是这套东西里最花心思的地方。351 个工具不是「把 REST 接口包一层」那么粗暴------每个都经过程度不低的策展。拿「改日程」背后的工具 declaration 看看:
csharp
[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", Required = true)] string calendar_id,
[ToolParameter("summary", "日程标题", Required = true)] string summary,
[ToolParameter("start", "开始时间(RFC3339,如 2026-10-01T14:00:00+08:00)", Required = true)] string start,
[ToolParameter("end", "结束时间(RFC3339,须晚于 start)", Required = true)] string end,
// ......description / timezone / idempotency_key / dry_run 略
);
}
这个声明里有几处值得说。Schema 是源生成器从这些 attribute 编译期生成的,零反射,AOT 友好;Source 三元组把工具锚回 SDK 方法,跟真实接口交叉校验,对不上直接构建失败------工具面和 SDK 悄悄错位这种事,在编译期拦下来最便宜。
权限由三个配置项控制:
| 闸门 | 管什么 | 默认 | 拦截时机 |
|---|---|---|---|
Tools 白名单 |
只读工具启用面 | 空(一个不启用) | 注册期 fail-fast:名单里出现未注册的名字直接拒启 |
WriteAllowList |
写工具启用面 | 空(一个写工具都不开) | 同上,且与只读名单读写分离、互斥校验 |
MaxToolRisk |
风险上限三档 | high-risk-write |
执行链最前端,超额工具零调用下游、不切租户 |
刚接上的 Agent「能读、不能写」------不是安全洁癖,是写接口出的事不可逆。等真要开写权限了,还有一道强制授权门禁(EnforceToolAuthorization 默认开)拦着:写操作必须过宿主实现的审批通道,模型自己拼一条「我批准了」塞回上下文是没用的,MAF 的 ApprovalResponseBindingChatClient 会做配对校验,配不上的一律不认。写工具参数里还留了 dry_run,模型不确定的时候可以先预演一遍要看下发的请求体------比直接干下去再回滚便宜得多。
再补一个只有踩过才会注意的细节:工具的时间参数一律 RFC3339。飞书内部并不统一------日历 v4 收 RFC3339,任务 v2 的 timestamp 却是毫秒字符串。要是两种格式都暴露给模型,它迟早毫秒当秒用,产出不报错但错得离谱的日程。所以格式转换被焊死在工具层,模型只感知一种格式,参数描述里再写死一遍,双保险。
工具结果回填也有门:先做白名单投影(不该进模型上下文的字段从源头剥掉),再按 MaxToolResultLength 截断------不然拉个两千行的多维表格,上下文窗口当场告爆炸。
会话历史:一个和直觉相反的语义
会话历史放 MAF 的状态袋里,键叫 feishu.agent.history,裁剪窗默认 50 条。存取走 IConversationStore,默认内存实现,分布式部署换 Redis 注册就行------事件入口一行不用改。
这部分本来没什么可写的,直到我们撞上那个坑。我一直以为历史裁剪是「请求视图」:超出窗口的旧消息这轮不发给模型而已,存储里还在。画个图对比一下我以为的和实际的:
#mermaid-svg-lRq66MlU0EKffeg8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lRq66MlU0EKffeg8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lRq66MlU0EKffeg8 .error-icon{fill:#552222;}#mermaid-svg-lRq66MlU0EKffeg8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lRq66MlU0EKffeg8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lRq66MlU0EKffeg8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lRq66MlU0EKffeg8 .marker.cross{stroke:#333333;}#mermaid-svg-lRq66MlU0EKffeg8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lRq66MlU0EKffeg8 p{margin:0;}#mermaid-svg-lRq66MlU0EKffeg8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lRq66MlU0EKffeg8 .cluster-label text{fill:#333;}#mermaid-svg-lRq66MlU0EKffeg8 .cluster-label span{color:#333;}#mermaid-svg-lRq66MlU0EKffeg8 .cluster-label span p{background-color:transparent;}#mermaid-svg-lRq66MlU0EKffeg8 .label text,#mermaid-svg-lRq66MlU0EKffeg8 span{fill:#333;color:#333;}#mermaid-svg-lRq66MlU0EKffeg8 .node rect,#mermaid-svg-lRq66MlU0EKffeg8 .node circle,#mermaid-svg-lRq66MlU0EKffeg8 .node ellipse,#mermaid-svg-lRq66MlU0EKffeg8 .node polygon,#mermaid-svg-lRq66MlU0EKffeg8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lRq66MlU0EKffeg8 .rough-node .label text,#mermaid-svg-lRq66MlU0EKffeg8 .node .label text,#mermaid-svg-lRq66MlU0EKffeg8 .image-shape .label,#mermaid-svg-lRq66MlU0EKffeg8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lRq66MlU0EKffeg8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lRq66MlU0EKffeg8 .rough-node .label,#mermaid-svg-lRq66MlU0EKffeg8 .node .label,#mermaid-svg-lRq66MlU0EKffeg8 .image-shape .label,#mermaid-svg-lRq66MlU0EKffeg8 .icon-shape .label{text-align:center;}#mermaid-svg-lRq66MlU0EKffeg8 .node.clickable{cursor:pointer;}#mermaid-svg-lRq66MlU0EKffeg8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lRq66MlU0EKffeg8 .arrowheadPath{fill:#333333;}#mermaid-svg-lRq66MlU0EKffeg8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lRq66MlU0EKffeg8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lRq66MlU0EKffeg8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lRq66MlU0EKffeg8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lRq66MlU0EKffeg8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lRq66MlU0EKffeg8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lRq66MlU0EKffeg8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lRq66MlU0EKffeg8 .cluster text{fill:#333;}#mermaid-svg-lRq66MlU0EKffeg8 .cluster span{color:#333;}#mermaid-svg-lRq66MlU0EKffeg8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lRq66MlU0EKffeg8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lRq66MlU0EKffeg8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lRq66MlU0EKffeg8 .icon-shape,#mermaid-svg-lRq66MlU0EKffeg8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lRq66MlU0EKffeg8 .icon-shape p,#mermaid-svg-lRq66MlU0EKffeg8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lRq66MlU0EKffeg8 .icon-shape .label rect,#mermaid-svg-lRq66MlU0EKffeg8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lRq66MlU0EKffeg8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lRq66MlU0EKffeg8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lRq66MlU0EKffeg8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} MessageCountingChatReducer 写回状态
实际的裁剪:结果写回存储
不可逆
整条丢弃
m1...m72 已被删除
只剩 m73...m80
含 FunctionCallContent 的消息
以为的裁剪:只是本次请求的视图
本轮只发送
存储里 m1...m80 全在
m73...m80
对,裁剪结果会写回会话状态,等于落库删除 ;更要命的是,带 FunctionCallContent 的消息------工具调用轨迹------直接整条丢掉。当时的现象是「模型忘了自己刚调过什么工具」,我们排查了一整圈不相干的地方,最后发现语义如此。如果你需要保留工具轨迹,得换一个不裁剪的 reducer 实现。
顺带一句:摘要器的保留窗是从裁剪窗派生的(MaxHistoryMessages / 2)------这两个旋母其实是一根轴,调小裁剪窗会连带收紧摘要。
回复:消息进来之后怎么发出去
前面讲的都是主动调用。AddFeishuImConversationHandler() 挂上之后,用户在飞书里 @机器人,一条消息进来,Agent 跑一轮,回复流式推回去。整条链路是这样:
#mermaid-svg-LkDUD9b9McaYcLhZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LkDUD9b9McaYcLhZ .error-icon{fill:#552222;}#mermaid-svg-LkDUD9b9McaYcLhZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LkDUD9b9McaYcLhZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LkDUD9b9McaYcLhZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LkDUD9b9McaYcLhZ .marker.cross{stroke:#333333;}#mermaid-svg-LkDUD9b9McaYcLhZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LkDUD9b9McaYcLhZ p{margin:0;}#mermaid-svg-LkDUD9b9McaYcLhZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ .cluster-label text{fill:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ .cluster-label span{color:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ .cluster-label span p{background-color:transparent;}#mermaid-svg-LkDUD9b9McaYcLhZ .label text,#mermaid-svg-LkDUD9b9McaYcLhZ span{fill:#333;color:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ .node rect,#mermaid-svg-LkDUD9b9McaYcLhZ .node circle,#mermaid-svg-LkDUD9b9McaYcLhZ .node ellipse,#mermaid-svg-LkDUD9b9McaYcLhZ .node polygon,#mermaid-svg-LkDUD9b9McaYcLhZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LkDUD9b9McaYcLhZ .rough-node .label text,#mermaid-svg-LkDUD9b9McaYcLhZ .node .label text,#mermaid-svg-LkDUD9b9McaYcLhZ .image-shape .label,#mermaid-svg-LkDUD9b9McaYcLhZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-LkDUD9b9McaYcLhZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LkDUD9b9McaYcLhZ .rough-node .label,#mermaid-svg-LkDUD9b9McaYcLhZ .node .label,#mermaid-svg-LkDUD9b9McaYcLhZ .image-shape .label,#mermaid-svg-LkDUD9b9McaYcLhZ .icon-shape .label{text-align:center;}#mermaid-svg-LkDUD9b9McaYcLhZ .node.clickable{cursor:pointer;}#mermaid-svg-LkDUD9b9McaYcLhZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LkDUD9b9McaYcLhZ .arrowheadPath{fill:#333333;}#mermaid-svg-LkDUD9b9McaYcLhZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LkDUD9b9McaYcLhZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LkDUD9b9McaYcLhZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LkDUD9b9McaYcLhZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LkDUD9b9McaYcLhZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LkDUD9b9McaYcLhZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LkDUD9b9McaYcLhZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LkDUD9b9McaYcLhZ .cluster text{fill:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ .cluster span{color:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LkDUD9b9McaYcLhZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LkDUD9b9McaYcLhZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-LkDUD9b9McaYcLhZ .icon-shape,#mermaid-svg-LkDUD9b9McaYcLhZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LkDUD9b9McaYcLhZ .icon-shape p,#mermaid-svg-LkDUD9b9McaYcLhZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LkDUD9b9McaYcLhZ .icon-shape .label rect,#mermaid-svg-LkDUD9b9McaYcLhZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LkDUD9b9McaYcLhZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LkDUD9b9McaYcLhZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LkDUD9b9McaYcLhZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否,自动降级
飞书 IM:@消息
WebSocket / Webhook 入口
签名校验 · 去重 · 解密
ImMessageConversationalEventHandler
上下文装配:群历史 / 媒体转写 / 知识检索
FeishuAgent.RunAsync
流式产出
CardKit Begin 成功?
CardKit 流式通道
卡片打字机效果
EditMessageChannel
分片编辑:200 字符一次,避开频率限制
降级链是这段的题眼:首选 CardKit 会话内流式(体验最好),起不来就自动退到分片编辑(稳但朴素)。事件处理器对这条链零感知------挂了就降级,业务代码不掺和。这两条通道直接决定了它说话像不像人:没有人愿意盯着一颗「对方正在输入...」等三十秒再看一大段蹦出来。
观测:出问题的时候查什么
遥测走一个中性端口 IAgentTelemetry,飞书侧实现打 feishu.agent.* 的 Span 和指标。接上 OTLP,Jaeger 里整条链路是通的:
#mermaid-svg-yHnaTIuBcCI453AU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yHnaTIuBcCI453AU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yHnaTIuBcCI453AU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yHnaTIuBcCI453AU .error-icon{fill:#552222;}#mermaid-svg-yHnaTIuBcCI453AU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yHnaTIuBcCI453AU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yHnaTIuBcCI453AU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yHnaTIuBcCI453AU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yHnaTIuBcCI453AU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yHnaTIuBcCI453AU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yHnaTIuBcCI453AU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yHnaTIuBcCI453AU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yHnaTIuBcCI453AU .marker.cross{stroke:#333333;}#mermaid-svg-yHnaTIuBcCI453AU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yHnaTIuBcCI453AU p{margin:0;}#mermaid-svg-yHnaTIuBcCI453AU .mermaid-main-font{font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-yHnaTIuBcCI453AU .exclude-range{fill:#eeeeee;}#mermaid-svg-yHnaTIuBcCI453AU .section{stroke:none;opacity:0.2;}#mermaid-svg-yHnaTIuBcCI453AU .section0{fill:rgba(102, 102, 255, 0.49);}#mermaid-svg-yHnaTIuBcCI453AU .section2{fill:#fff400;}#mermaid-svg-yHnaTIuBcCI453AU .section1,#mermaid-svg-yHnaTIuBcCI453AU .section3{fill:white;opacity:0.2;}#mermaid-svg-yHnaTIuBcCI453AU .sectionTitle0{fill:#333;}#mermaid-svg-yHnaTIuBcCI453AU .sectionTitle1{fill:#333;}#mermaid-svg-yHnaTIuBcCI453AU .sectionTitle2{fill:#333;}#mermaid-svg-yHnaTIuBcCI453AU .sectionTitle3{fill:#333;}#mermaid-svg-yHnaTIuBcCI453AU .sectionTitle{text-anchor:start;font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-yHnaTIuBcCI453AU .grid .tick{stroke:lightgrey;opacity:0.8;shape-rendering:crispEdges;}#mermaid-svg-yHnaTIuBcCI453AU .grid .tick text{font-family:"trebuchet ms",verdana,arial,sans-serif;fill:#333;}#mermaid-svg-yHnaTIuBcCI453AU .grid path{stroke-width:0;}#mermaid-svg-yHnaTIuBcCI453AU .today{fill:none;stroke:red;stroke-width:2px;}#mermaid-svg-yHnaTIuBcCI453AU .task{stroke-width:2;}#mermaid-svg-yHnaTIuBcCI453AU .taskText{text-anchor:middle;font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutsideRight{fill:black;text-anchor:start;font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutsideLeft{fill:black;text-anchor:end;}#mermaid-svg-yHnaTIuBcCI453AU .task.clickable{cursor:pointer;}#mermaid-svg-yHnaTIuBcCI453AU .taskText.clickable{cursor:pointer;fill:#003163!important;font-weight:bold;}#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutsideLeft.clickable{cursor:pointer;fill:#003163!important;font-weight:bold;}#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutsideRight.clickable{cursor:pointer;fill:#003163!important;font-weight:bold;}#mermaid-svg-yHnaTIuBcCI453AU .taskText0,#mermaid-svg-yHnaTIuBcCI453AU .taskText1,#mermaid-svg-yHnaTIuBcCI453AU .taskText2,#mermaid-svg-yHnaTIuBcCI453AU .taskText3{fill:white;}#mermaid-svg-yHnaTIuBcCI453AU .task0,#mermaid-svg-yHnaTIuBcCI453AU .task1,#mermaid-svg-yHnaTIuBcCI453AU .task2,#mermaid-svg-yHnaTIuBcCI453AU .task3{fill:#8a90dd;stroke:#534fbc;}#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutside0,#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutside2{fill:black;}#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutside1,#mermaid-svg-yHnaTIuBcCI453AU .taskTextOutside3{fill:black;}#mermaid-svg-yHnaTIuBcCI453AU .active0,#mermaid-svg-yHnaTIuBcCI453AU .active1,#mermaid-svg-yHnaTIuBcCI453AU .active2,#mermaid-svg-yHnaTIuBcCI453AU .active3{fill:#bfc7ff;stroke:#534fbc;}#mermaid-svg-yHnaTIuBcCI453AU .activeText0,#mermaid-svg-yHnaTIuBcCI453AU .activeText1,#mermaid-svg-yHnaTIuBcCI453AU .activeText2,#mermaid-svg-yHnaTIuBcCI453AU .activeText3{fill:black!important;}#mermaid-svg-yHnaTIuBcCI453AU .done0,#mermaid-svg-yHnaTIuBcCI453AU .done1,#mermaid-svg-yHnaTIuBcCI453AU .done2,#mermaid-svg-yHnaTIuBcCI453AU .done3{stroke:grey;fill:lightgrey;stroke-width:2;}#mermaid-svg-yHnaTIuBcCI453AU .doneText0,#mermaid-svg-yHnaTIuBcCI453AU .doneText1,#mermaid-svg-yHnaTIuBcCI453AU .doneText2,#mermaid-svg-yHnaTIuBcCI453AU .doneText3{fill:black!important;}#mermaid-svg-yHnaTIuBcCI453AU .doneText0.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneText0.taskTextOutsideRight,#mermaid-svg-yHnaTIuBcCI453AU .doneText1.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneText1.taskTextOutsideRight,#mermaid-svg-yHnaTIuBcCI453AU .doneText2.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneText2.taskTextOutsideRight,#mermaid-svg-yHnaTIuBcCI453AU .doneText3.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneText3.taskTextOutsideRight{fill:black!important;}#mermaid-svg-yHnaTIuBcCI453AU .crit0,#mermaid-svg-yHnaTIuBcCI453AU .crit1,#mermaid-svg-yHnaTIuBcCI453AU .crit2,#mermaid-svg-yHnaTIuBcCI453AU .crit3{stroke:#ff8888;fill:red;stroke-width:2;}#mermaid-svg-yHnaTIuBcCI453AU .activeCrit0,#mermaid-svg-yHnaTIuBcCI453AU .activeCrit1,#mermaid-svg-yHnaTIuBcCI453AU .activeCrit2,#mermaid-svg-yHnaTIuBcCI453AU .activeCrit3{stroke:#ff8888;fill:#bfc7ff;stroke-width:2;}#mermaid-svg-yHnaTIuBcCI453AU .doneCrit0,#mermaid-svg-yHnaTIuBcCI453AU .doneCrit1,#mermaid-svg-yHnaTIuBcCI453AU .doneCrit2,#mermaid-svg-yHnaTIuBcCI453AU .doneCrit3{stroke:#ff8888;fill:lightgrey;stroke-width:2;cursor:pointer;shape-rendering:crispEdges;}#mermaid-svg-yHnaTIuBcCI453AU .milestone{transform:rotate(45deg) scale(0.8,0.8);}#mermaid-svg-yHnaTIuBcCI453AU .milestoneText{font-style:italic;}#mermaid-svg-yHnaTIuBcCI453AU .doneCritText0,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText1,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText2,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText3{fill:black!important;}#mermaid-svg-yHnaTIuBcCI453AU .doneCritText0.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText0.taskTextOutsideRight,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText1.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText1.taskTextOutsideRight,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText2.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText2.taskTextOutsideRight,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText3.taskTextOutsideLeft,#mermaid-svg-yHnaTIuBcCI453AU .doneCritText3.taskTextOutsideRight{fill:black!important;}#mermaid-svg-yHnaTIuBcCI453AU .vert{stroke:navy;}#mermaid-svg-yHnaTIuBcCI453AU .vertText{font-size:15px;text-anchor:middle;fill:navy!important;}#mermaid-svg-yHnaTIuBcCI453AU .activeCritText0,#mermaid-svg-yHnaTIuBcCI453AU .activeCritText1,#mermaid-svg-yHnaTIuBcCI453AU .activeCritText2,#mermaid-svg-yHnaTIuBcCI453AU .activeCritText3{fill:black!important;}#mermaid-svg-yHnaTIuBcCI453AU .titleText{text-anchor:middle;font-size:18px;fill:#333;font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-yHnaTIuBcCI453AU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 0 0 0 0 0 0 0 0 0 0 feishu.agent.turn chat.completion(模型) tool.calendar.list_events tool.calendar.update_event reply.stream(流式出站) 回合 子 Span 一次回合的 Span 瀑布(相对时间)
没有这条链路,「为什么它没调工具」基本只能靠猜;有了它,至少能猜得有依据。
几个踩过的坑
几周攒下来的坑,按影响从大到小排:
| 坑 | 现象 | 病因 | 解法 |
|---|---|---|---|
| 历史裁剪是落库删除 | 模型忘了刚调过的工具 | reducer 把裁剪结果写回状态,工具轨迹整条丢 | 需要轨迹就换不裁剪的 reducer |
GetService 没先判自身 |
监控里少一段 Span,无报错 | 委托直接命中内层 ChatClientAgent,外层织入被绕过 |
别手写,用基类的委托语义 |
| 状态袋想存自定义类型 | AOT 下序列化炸 | 自定义类型要备 JsonTypeInfo |
状态袋只存 string(如 pending_reply) |
| keyed 客户端配错 | 运行期首调才 null 炸 | 解析失败被透传 | 解析即校验,装配期就抛 |
最后说两句
整套东西的立场就一句:MAF 出骨架,飞书 SDK 出血肉。框架管所有 Agent 都要面对的通用问题,SDK 管产品事实------哪些工具、什么权限、什么时间格式、哪条流式通道。边界干净,所以两边各自升级互不拖累。
最后再抬一手:同一套工具面还能以 MCP server 的形式跑出去(AddFeishuMcpServer,stdio 和 Streamable HTTP 两种传输)。Claude Code、Cursor 这些进程外的 Agent 接上来,用的工具、授权、审计跟进程内完全一致------你给团队搭的这个飞书助手,顺手也能让隔壁写代码的 Agent 用上同一套工具。工具面策展这份投入,回报比想象的大。
仓库在 MudFeishu,AI 部分的深度文档在 documents/AIAgent/ 下。想搭自己团队的工作搭子的,从 README 快速开始那节抄起,五分钟见雏形。