拓冰建站拓冰建站
首页 / 资讯中心 / 正文

为Hermes Agent智能体框架扩展飞书消息卡片表格推送功能实践

1. 项目背景与需求缘起最近在折腾一个内部效率工具核心是让一个叫 Hermes Agent 的智能体框架能够自动处理一些日常任务比如监控数据、生成报告然后推送到飞书工作群里。这个框架本身挺有意思号称“开箱即用”部署起来也确实没费太大劲。但问题很快就来了默认的消息推送就是纯文本或者简单的 Markdown 格式。当我们需要推送一个包含多行数据、需要清晰对比的日报或者周报时纯文本就显得非常混乱而飞书消息卡片里那个基础的markdown字段对复杂表格的支持又很弱排版经常惨不忍睹。我们团队的习惯是任何需要多人快速查阅的数据最好能以表格形式直观地呈现在消息里而不是附上一个需要额外点击的链接或文件。这就引出了核心需求如何让 Hermes Agent 在通过飞书 Webhook 发送消息时能够生成并渲染出格式美观、行列清晰的表格简单说就是给这个“开箱即用”的 Agent 临时增加一个“飞书消息通道卡片表格支持”的能力。这不算对框架本身的大改更像是一个针对特定输出格式的“插件”或“补丁”。网上搜了一圈关于 Hermes Agent 的具体集成案例不多但结合飞书开放平台的消息卡片文档和 Hermes 的扩展思路摸索出了一套可行的方案。整个过程涉及对 Hermes 消息处理流程的理解、飞书卡片结构的构建以及如何在不动核心代码的前提下进行注入。下面我就把这次实践的完整过程、核心原理和踩过的坑详细分享一下如果你也在用类似框架对接飞书需要强化消息展示能力这篇内容应该能帮你省下不少摸索的时间。2. 理解 Hermes Agent 的消息分发机制要给 Hermes Agent 增加新功能首先得摸清它的消息是怎么产生并发送出去的。Hermes Agent 作为一个智能体框架其核心工作流可以简化为感知输入- 思考处理- 行动输出。我们关心的“发送飞书消息”这个动作属于“行动”环节的一部分。通过查阅官方文档和源码结构通常位于hermes-core或agent相关模块可以发现消息通道Channel是解耦的关键。框架会定义一个Message抽象以及一个Channel接口。不同的通道如飞书、钉钉、命令行实现这个接口。当 Agent 决定要发送消息时它会将消息内容和一个目标通道标识传递给消息路由器Message Router由路由器找到对应的Channel实现去执行发送。关键点在于消息内容的结构。在 Hermes 的默认实现中Message对象可能只包含一个content字符串字段或者一个简单的type如text,markdown加content的结构。这对于基础文本足够了但要支持飞书卡片这种富交互内容就显得力不从心。飞书卡片是一个遵循特定 JSON Schema 的复杂对象包含header,elements等嵌套结构。直接将一个表格文本塞进content飞书机器人是无法识别并渲染成表格的。因此我们的改造目标很明确扩展 Hermes 的Message模型和飞书Channel的实现使其能够承载并处理“卡片”这种富消息类型并能在卡片中嵌入表格元素。这需要我们在两个层面操作一是定义一种新的、能描述卡片结构的数据模型二是修改或扩写飞书通道的发送逻辑使其能将这个数据模型正确转换为飞书 API 所需的 JSON 格式。一个常见的误区是直接去修改框架的核心消息类。更好的做法是利用框架的扩展机制如果提供或者通过继承、组合的方式在不污染原有代码的前提下增加新特性。幸运的是像 Hermes 这类现代框架通常在设计时就会考虑扩展性。3. 飞书消息卡片与表格元素的 JSON 结构剖析要让 Hermes 能生成飞书表格我们必须先成为飞书卡片协议的“专家”。飞书开放平台的消息卡片是一种基于 JSON 的数据结构通过机器人 webhook 或 SDK 发送。一个最简单的文本卡片长这样{ msg_type: interactive, card: { elements: [{ tag: div, text: { tag: lark_md, content: 这是一条普通文本消息 } }] } }但这离我们的表格需求还很远。飞书卡片支持一个名为table的元素它是实现我们需求的核心。一个完整的、带标题和内容的表格卡片结构如下{ msg_type: interactive, card: { header: { title: { tag: plain_text, content: 每日数据报表 } }, elements: [ { tag: table, flex_mode: none, border_stroke: default, columns: [ {width: weighted, weight: 2, horizontal_align: left}, {width: weighted, weight: 2, horizontal_align: center}, {width: weighted, weight: 1, horizontal_align: right} ], rows: [ { cells: [ {tag: plain_text, content: 项目名称}, {tag: plain_text, content: 负责人}, {tag: plain_text, content: 进度} ] }, { cells: [ {tag: plain_text, content: 用户画像分析}, {tag: plain_text, content: 张三}, {tag: lark_md, content: **85%**} ] } ] } ] } }让我们拆解一下这个结构的关键部分msg_type: 必须为interactive告诉飞书这是一个交互卡片。card.header: 可选的卡片标题区。给表格加个标题会让消息更清晰。card.elements: 卡片的正文内容区是一个数组可以放置多个元素。我们的table就在这里。table元素:tag: 固定为table。columns: 定义列。每个列对象可以定义宽度width如weighted按权重分配或px固定像素、权重weight用于加权分配、对齐方式horizontal_align。rows: 定义行。每行是一个对象包含一个cells数组。每个单元格cell本身又是一个元素可以是plain_text纯文本或lark_md支持加粗、颜色等简单 Markdown 的文本。第一行通常用作表头。这里有一个非常重要的细节飞书卡片的table元素本身不支持复杂的单元格合并或嵌套它就是一个规整的二维表格。如果你的数据需要合并单元格需要在数据层面先处理好或者考虑用多个div元素配合文本来模拟但那会复杂很多且可能破坏对齐。对于大多数报表场景规整表格已经足够。理解了目标数据结构我们的任务就变成了如何在 Hermes Agent 内部构造出这样一个 JSON 对象并确保它能被正确发送。我们需要一个中间层将业务数据比如一个 List of Map 或二维数组转换为此 JSON 结构。4. 设计 Hermes 的卡片消息扩展方案既然不能硬改核心代码我们就需要设计一个“非侵入式”的扩展方案。这里提供两种主流思路我会详细分析其优劣和实现细节。4.1 方案一扩展 Message 实体与专用 Channel这是最直观、最符合框架设计哲学的方式。我们创建新的消息类型。第一步定义富文本卡片消息类我们创建一个新的 Java 类假设 Hermes 是 Java 系框架例如FeishuCardMessage它继承或包含基础的Message属性并额外包含一个cardJson字段或者更优雅点包含一个FeishuCard数据对象这个对象内部封装了构建 JSON 的逻辑。// 示例伪代码需根据实际框架调整 public class FeishuCardMessage extends BaseMessage { private FeishuCard card; public FeishuCardMessage(FeishuCard card) { super(MessageType.INTERACTIVE_CARD); // 定义一个新的消息类型枚举 this.card card; } public String getCardJson() { // 使用 Jackson/Gson 将 card 对象序列化为 JSON 字符串 return objectMapper.writeValueAsString(card); } } // FeishuCard 是一个 POJO其字段与飞书卡片 JSON 结构对应 Data public class FeishuCard { private CardHeader header; private ListCardElement elements; // ... 其他卡片配置字段如 config }第二步创建或扩展飞书 Channel原有的FeishuChannel可能只处理text或markdown类型的消息。我们需要修改它的send方法增加对MessageType.INTERACTIVE_CARD或FeishuCardMessage类型的判断。public class EnhancedFeishuChannel implements Channel { Override public void send(Message message) { if (message instanceof FeishuCardMessage) { FeishuCardMessage cardMsg (FeishuCardMessage) message; String jsonPayload cardMsg.getCardJson(); // 调用飞书 Webhook发送这个 jsonPayload callFeishuWebhook(jsonPayload); } else if (message.getType() MessageType.TEXT) { // 原有的文本消息处理逻辑 sendText(message.getContent()); } // ... 其他类型 } }第三步提供表格构建工具类为了方便业务代码生成表格我们可以提供一个TableCardBuilder工具类。public class TableCardBuilder { public static FeishuCardMessage buildTableMessage(String title, ListString headers, ListListObject rows) { FeishuCard card new FeishuCard(); card.setHeader(new CardHeader(title)); TableElement table new TableElement(); // 根据 headers 构建 columns ListTableColumn columns headers.stream().map(h - new TableColumn(...)).collect(Collectors.toList()); table.setColumns(columns); // 将 rows 数据转换为 table rows ListTableRow tableRows convertRowsToTableRows(rows); table.setRows(tableRows); card.setElements(Arrays.asList(table)); return new FeishuCardMessage(card); } }这个方案的优点是结构清晰、类型安全、易于测试和复用。缺点是可能需要修改框架的 Channel 注册机制确保框架使用的是我们增强后的EnhancedFeishuChannel而不是原来的。如果框架支持 SPIService Provider Interface或依赖注入替换起来会比较容易。4.2 方案二利用消息内容标记与通用处理器如果框架扩展起来比较麻烦或者你想做一个更轻量、更临时的 hack可以考虑这个方案。这个方案的核心思想是不定义新消息类型而是在原有文本消息的内容上做标记由一个后置处理器来识别并转换。第一步约定一种标记语法例如我们约定如果消息内容以[TABLE]开头后面跟随一个特定的格式比如 JSON 或简化 DSL那么就认为这条消息需要被渲染成表格。[TABLE] title: 项目进度表 headers: 项目,负责人,进度 rows: [[需求分析, 小李, 100%], [UI设计, 小王, 80%], [后端开发, 小张, 60%]]或者直接用一个 JSON 字符串[TABLE]{title:..., headers:[...], rows:[...]}第二步创建消息后置处理器Interceptor/Post-Processor在消息被发送到 Channel 之前框架通常会有拦截器链。我们可以插入一个自定义的FeishuTableProcessor。public class FeishuTableProcessor implements MessageProcessor { Override public Message process(Message message) { if (message.getType() MessageType.TEXT) { String content message.getContent(); if (content.startsWith([TABLE])) { // 1. 解析标记提取表格数据 TableData tableData parseTableMark(content); // 2. 根据表格数据构建飞书卡片 JSON 字符串 String cardJson buildCardJson(tableData); // 3. 创建一个“新”的消息或者修改原消息 // 这里需要改变消息类型和内容。如果框架允许可以创建一个新的包含原始消息和 cardJson 的包装对象。 // 更直接但有点脏的方法是修改 message 的 content 为 cardJson并偷偷改掉 type如果字段可写。 // 假设我们创建一个新的内部使用的 RichMessage return new RichMessage(cardJson, feishu_card); } } return message; } }第三步修改飞书 Channel 以处理“富消息”飞书 Channel 的send方法需要能处理这种内部使用的RichMessage或者能识别content已经是完整的卡片 JSON。public class FeishuChannel implements Channel { Override public void send(Message message) { String payload; if (message instanceof RichMessage feishu_card.equals(((RichMessage) message).getSubType())) { payload message.getContent(); // content 已经是构建好的 JSON } else if (message.getContent().startsWith({)) { // 试探性判断如果内容以 { 开头可能是 JSON尝试直接发送有风险 try { new JsonParser().parse(message.getContent()); payload message.getContent(); } catch (Exception e) { payload buildTextPayload(message.getContent()); } } else { payload buildTextPayload(message.getContent()); } callFeishuWebhook(payload); } }这个方案的优点是侵入性极低几乎不需要改动现有业务代码和框架核心只需要添加一个处理器并微调 Channel。缺点也很明显它破坏了消息模型的纯洁性依赖于字符串标记和约定容易出错不易维护和扩展。它更像一个临时解决方案。如何选择如果你的项目长期需要此功能且框架允许强烈推荐方案一。如果只是快速验证、临时需求或者框架封闭难以扩展方案二可以作为捷径。下文将主要基于方案一展开实现细节。5. 实现步骤详解从数据到飞书表格假设我们选择了方案一并且框架支持替换或扩展 Channel。以下是具体的实现步骤。5.1 环境准备与依赖确认首先确保你的 Hermes Agent 项目环境是可控的。确认框架版本与扩展点仔细阅读 Hermes 的官方文档找到关于Channel注册、Message类型定义、依赖注入如使用 Spring的相关说明。查看是否有Plugin、Extension之类的注解。添加 JSON 处理库如果项目中没有添加如 Jackson 或 Gson 依赖用于对象与 JSON 的序列化/反序列化。!-- Maven 示例 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.0/version /dependency准备飞书机器人在飞书开放平台创建一个自定义机器人获取其Webhook URL。确保该机器人在需要发送消息的群聊中。5.2 定义数据模型与构建器按照方案一我们创建一系列 POJO 来映射飞书卡片结构。// CardHeader.java Data Builder public class CardHeader { private CardTitle title; // 飞书卡片 header 还有其他字段如 subtitle, template按需添加 } // CardTitle.java Data Builder public class CardTitle { private String tag plain_text; // 通常表头用纯文本 private String content; } // TableElement.java Data Builder public class TableElement implements CardElement { private String tag table; private String flex_mode none; private String border_stroke default; private ListTableColumn columns; private ListTableRow rows; } // TableColumn.java Data Builder public class TableColumn { private String width weighted; private Integer weight 1; // 默认权重 private String horizontal_align left; } // TableRow.java Data public class TableRow { private ListTableCell cells; } // TableCell.java - 注意单元格本身又是一个元素 Data Builder public class TableCell { private String tag; // plain_text 或 lark_md private String content; }然后创建一个强大的FeishuCardBuilder它应该提供流畅的 API让构建卡片变得简单。public class FeishuCardBuilder { private FeishuCard card new FeishuCard(); private ListCardElement elements new ArrayList(); public static FeishuCardBuilder newBuilder() { return new FeishuCardBuilder(); } public FeishuCardBuilder withHeader(String title) { CardHeader header CardHeader.builder() .title(CardTitle.builder().content(title).build()) .build(); card.setHeader(header); return this; } public FeishuCardBuilder addTable(String[] headers, ListListObject dataRows) { TableElement table new TableElement(); // 构建列 ListTableColumn columns Arrays.stream(headers) .map(h - TableColumn.builder().weight(1).build()) .collect(Collectors.toList()); table.setColumns(columns); // 构建行表头行 数据行 ListTableRow rows new ArrayList(); // 表头行 TableRow headerRow new TableRow(); headerRow.setCells(Arrays.stream(headers) .map(h - TableCell.builder().tag(plain_text).content(h).build()) .collect(Collectors.toList())); rows.add(headerRow); // 数据行 for (ListObject rowData : dataRows) { TableRow dataRow new TableRow(); ListTableCell cells rowData.stream() .map(cellObj - { String content String.valueOf(cellObj); // 简单启发式判断如果内容包含 ** 或 则认为需要 Markdown 渲染 String tag (content.contains(**) || content.contains()) ? lark_md : plain_text; return TableCell.builder().tag(tag).content(content).build(); }) .collect(Collectors.toList()); dataRow.setCells(cells); rows.add(dataRow); } table.setRows(rows); elements.add(table); return this; } public FeishuCard build() { card.setElements(elements); return card; } // 将 Card 对象转换为最终发送的 JSON 字符串 public String buildJson() throws JsonProcessingException { ObjectMapper mapper new ObjectMapper(); MapString, Object payload new HashMap(); payload.put(msg_type, interactive); payload.put(card, build()); return mapper.writeValueAsString(payload); } }5.3 集成到 Hermes Agent 的业务逻辑中现在在你的 Agent 业务代码可能是某个Skill或Action中当需要发送表格时可以这样使用public class ReportGenerationSkill implements Skill { Autowired private MessageService messageService; // 假设 Hermes 提供的消息发送服务 public void executeDailyReport() { // 1. 模拟获取数据 String[] headers {项目, 负责人, 完成度, 风险}; ListListObject data Arrays.asList( Arrays.asList(用户画像系统, 张三, **95%**, 低), Arrays.asList(推荐算法优化, 李四, 70%, 中), Arrays.asList(数据看板重构, 王五, **50%**, 高) ); // 2. 构建飞书卡片消息 String cardJson FeishuCardBuilder.newBuilder() .withHeader(项目日报 - LocalDate.now()) .addTable(headers, data) .buildJson(); // 3. 创建消息并发送 (这里需要你根据框架调整可能是发送一个特定事件或调用特定API) // 假设我们通过一个自定义的发送方法 FeishuCardMessage cardMessage new FeishuCardMessage(cardJson); messageService.sendToChannel(cardMessage, feishu); // 指定飞书通道 } }5.4 配置与注册增强的飞书通道这是最关键的一步确保框架使用我们新写的通道。具体方法取决于 Hermes 的架构。如果使用 Spring Boot你可以通过Component或Service注解你的EnhancedFeishuChannel并确保它实现了框架定义的Channel接口。框架可能通过Autowired集合ListChannel或一个ChannelRegistry来管理通道。你可能需要调整 Bean 的名称或 Qualifier确保你的实现覆盖了默认的飞书通道。如果使用配置文件查看 Hermes 的配置文件如application.yml看是否有通道实现的类路径配置。将其指向你的com.yourcompany.hermes.ext.feishu.EnhancedFeishuChannel。如果框架有明确的插件/扩展目录将你的扩展类打成 JAR 包放到指定的plugins目录下框架可能会自动加载。一个常见的坑是依赖冲突或加载顺序问题。确保你的扩展模块对框架核心的依赖版本与主项目一致。启动时关注日志中关于 Channel 初始化的部分确认你的EnhancedFeishuChannel被成功加载并注册。6. 实测中的问题排查与优化技巧理论跑通后实际集成一定会遇到问题。下面是我在实测中遇到的一些典型问题及解决方法。6.1 飞书 Webhook 报错 “invalid request”这是最常遇到的问题。原因和排查步骤如下JSON 格式错误这是首要怀疑对象。使用在线的 JSON 格式化验证工具如 jsonlint.com检查buildJson()方法最终生成的字符串。特别注意最后一个元素后不能有逗号。字符串必须用双引号不能用单引号。布尔值和null不用引号。将构建好的 JSON 字符串先通过 Postman 或 curl 直接调用飞书 Webhook URL 进行测试这样可以隔离 Hermes 框架的问题。编码问题确保你的字符串在传输过程中编码正确UTF-8。在 HTTP 请求头中明确指定Content-Type: application/json; charsetutf-8。卡片结构不符合飞书 Schema仔细对照飞书官方文档检查必填字段是否齐全。例如msg_type必须是interactivecard对象必须存在table的columns和rows不能为空数组。一个容易被忽略的点是table的cells里的每个元素其tag和content是平级的不要嵌套错了。Webhook URL 过期或权限不足飞书机器人的 Webhook URL 如果泄露可能会被重置。去开放平台重新复制一份。确保机器人已加入目标群聊并且拥有发送消息的权限。6.2 表格渲染不美观或错位列宽分配不均默认所有列weight为 1平均分配。如果某列内容特别长可以适当增加其weight值如设为 2 或 3让它占据更多空间。内容过长被截断飞书卡片表格对单元格内容长度有限制过长的文本会被截断并显示“...”。对于长文本有两个选择精简内容在数据源处做摘要。使用lark_md并换行Markdown 内容支持\n换行可以将长文本分成多行显示但体验不一定好。更好的办法是将详细内容放在卡片后面的div文本中表格只展示关键指标。数字或特殊字符对齐对于数字、金额建议将列的对齐方式horizontal_align设置为right右对齐更符合阅读习惯。6.3 Hermes 框架集成时的时序与并发问题消息发送失败但无异常检查 Hermes 的消息发送是否是异步的。如果是失败可能只在后台日志中体现。你需要配置好日志框架如 Logback/SLF4J确保WARN和ERROR级别的日志被捕获并检查 Channel 实现中的异常处理逻辑。Channel 未生效确认你的EnhancedFeishuChannelBean 的优先级高于默认实现。在 Spring 中可以使用Primary注解或者在注入时使用Qualifier。查看启动日志搜索你的 Channel 类名看是否被初始化。在 Agent 多实例部署下确保每个实例的配置都正确特别是 Webhook URL。可以考虑将 Webhook URL 放在外部配置中心或环境变量中而不是硬编码在代码里。6.4 性能与可维护性优化缓存卡片模板如果表格结构固定只有数据变化如日报、周报可以预先构建好不带数据的卡片 JSON 模板包含header,table的columns和空rows每次发送时只需用 Jackson 的JsonNode或类似工具填充rows数据避免重复构建整个 JSON 对象提升性能。抽象消息发送服务不要在每个Skill里都写构建和发送的代码。抽象一个FeishuMessageSender服务提供如sendTable(String title, ListMap data)这样的高级接口。这样业务逻辑更清晰也便于后续统一更换消息通道或增加重试机制。增加发送重试与降级网络请求可能失败。在EnhancedFeishuChannel的callFeishuWebhook方法中加入简单的重试逻辑如最多3次指数退避。如果重试后仍失败可以考虑降级为发送一条普通的文本告警消息或者将失败任务放入一个待重试队列。监控与告警对消息发送的成功/失败率进行监控。可以在发送成功后记录一条 INFO 日志失败时记录 ERROR 日志并附带错误信息。甚至可以集成监控系统当连续发送失败时触发告警。7. 扩展思考超越基础表格实现了基础表格后可以进一步思考如何让这个功能更强大、更智能。支持更复杂的数据类型目前的单元格只支持文本和简单 Markdown。可以扩展TableCell使其支持image图片、button按钮等飞书卡片支持的元素从而在表格中嵌入操作按钮如“查看详情”、“确认完成”。动态表格与交互飞书卡片支持action交互行为。可以为表格的每一行添加一个按钮点击后触发 Hermes Agent 的另一个Skill实现“审批”、“打标签”等轻交互。这需要 Hermes 能够接收和处理飞书回传的回调事件。与数据源深度集成你的TableCardBuilder可以直接从数据库、API 或内部数据平台拉取数据实现真正的“开箱即用定时推送”。将数据查询逻辑也封装进去配置好数据源和 SQL/查询语句就可以作为一个独立的报表推送模块。多通道适配虽然本文聚焦飞书但同样的FeishuCardMessage思想可以应用到钉钉、企业微信等。可以定义一个更通用的RichCardMessage然后为不同平台实现不同的CardRenderer实现“一次构建多端渲染”。样式主题化通过配置文件定义不同的表格样式主题如颜色、对齐方式、是否显示边框等让不同的报告类型拥有不同的视觉风格提升可读性。这次给 Hermes Agent 增加飞书卡片表格支持的过程本质上是一次对框架消息系统的深度定制。它考验的不仅仅是对飞书 API 的调用更是对所用框架扩展能力的理解和解耦设计的能力。从最开始的“能不能做”到后来的“怎么做更优雅、更稳健”每一步都需要仔细权衡。最终实现的不仅仅是一个功能而是一个可复用的、易于维护的消息富文本化方案的基础。希望这个详细的拆解能帮助你在面对类似“开箱即用框架的功能增强”需求时有一个清晰的解决思路和实操路径。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门