智群 AgentTeam 更新:智能体会自己装Skill、上网查资料了——连思考过程都看得见
后端仓库:agent-team-server | 桌面壳(含前端 web/):agent-team-desktop
上篇:《智群 AgentTeam 更新:讨论不冷场、状态看得见、记录带得走——一次全面收口》
写在前面
老规矩,先给新朋友补背景:AgentTeam 是一个聊天应用布局的 AI 智能体团队系统——你是公司里唯一的真人「老板」,通讯录里全是各怀绝技的 AI 员工。单聊交代任务、拉群推进项目,@一下项目经理,他会自动拆解需求、委派成员协作,干完把总结发回你的单聊。基于 Vue 3 + Electron + Spring Boot + AgentScope 实现,兼容各类 OpenAI 兼容模型,也有开箱即用的桌面安装包。
之前智能体的能力边界都是「出厂设置」:你能配什么,他就会什么——绑个文生图预设就会画画,接上语音识别就能听写。这期干的事情性质不一样了:给智能体开了两条自己长能力的路。一条向内——技能系统,智能体可以在对话里自助安装新技能(弹卡片向你报备),装完即用;一条向外——联网,web_search搜全网、fetch_webpage抓正文,查完还会把「查了什么」摆给你看。
主角是技能系统:智能体第一次可以「自己长能力」——缺什么自己装,装之前报备。两个配套让这件事看得住:联网的每一步留痕可查;体验上最大的升级,是把推理模型的思考过程也透明了——逐字透出、全文可回看(想想看,一个会自己装技能的智能体,你肯定更想知道它「怎么想」)。再配上两场硬核排查(并发绑定覆盖、5 分钟框架超时)和一批交互收口。
上期的主题词是「收口」,这期的主题词是「自生长」:能力自己长,过程全透明。展开讲。
本次更新一览
- 智能体技能系统:「技能」页用 Markdown 编写技能文档(可带辅助文件),全局或逐智能体启用;智能体对话中可用
install_skill自助安装(弹审批卡片报备)、list_skills查看已装技能与「含你/不含你」绑定标注——本期主打,智能体能力从「被配置」走向「自生长」 - 联网能力:所有智能体自带
web_search(AnySearch 搜索)与fetch_webpage(jsoup 抓取正文)两个只读免审批工具,单聊/群聊、编排协作全链路可用;零配置匿名即用,设置页可填 AnySearch API Key 提升额度;GBK 老站点不乱码 - 联网记录可视化:智能体搜索/抓取时聊天页实时显示「正在搜索/正在抓取」提示条,回复气泡下方新增「联网记录」折叠条——每次搜索的关键词与站点、抓取的网址与标题(失败含原因)随消息持久化,历史会话可回看
- 思考过程实时可见:推理模型思考期间思考文本逐字显示在思考条(单聊/编排者)或成员气泡内(协作被委派成员),思考一结束立即在气泡上方出现「思考过程」折叠条(群聊有名字时位于名字与气泡之间);思考全文随回复持久化(独立字段,不进对话上下文),单聊/群聊/定时任务/历史会话全场景可回看,导出聊天记录一并带出
- 修复长任务恰好 5 分钟被掐断:框架对工具执行的默认总上限只有 5 分钟,而
delegate的整个成员轮都跑在工具调用里——长任务到点必死,还误报「协作已被用户终止」;上限放开至 120 分钟,终止判定只认用户停止标志 - 修复并行安装技能只绑定一个:同一轮并行执行多个
install_skill时智能体技能绑定互相覆盖——一次顺着工具并发模型挖到字节码层的排查 - 委派任务卡退出消息流:协作群里不再刷「【委派任务卡 → 成员】」大卡片,完整任务卡改记运行日志;群里只看编排者的自然语言安排说明与成员输出
- 交互收口:设置弹窗分区手风琴折叠 + 分割线;各页空状态图标与侧栏统一;弹出层「按下+松开都在外面」才关闭;消息输入框可拖拽拉伸;后台回合切页后思考动画不再消失;错误日志叠加完整异常链
一、技能系统:Markdown 即能力,装完即用
技能长什么样
一个技能就是一份 Markdown:data/skills/<skillId>/SKILL.md,frontmatter 写名称与描述,正文想写多细写多细,旁边可以躺脚本、模板、参考文档等辅助文件。技能页做 CRUD,改完即时生效、不用重启。
生效的机制是注入而非魔法:所有构建智能体的地方(单聊/群聊成员、编排者、协作中被 delegate 的成员)都会把技能目录(ID + 名称 + 描述)注入系统提示,智能体判断哪个跟当前任务相关,再自行加载正文。注入的是「目录」不是「全文」——十个技能全量注进去,上下文先爆的是自己。
绑定关系存agents.skill_ids(V14 迁移),支持两种粒度:「全局」(所有智能体含以后新建的都可用)或逐智能体勾选,技能页与智能体编辑表单操作同一份数据、求并集生效。
自助安装:智能体给自己长能力
@Tool(name="install_skill",concurrencySafe=false,description="Install a skill ... "+"into the skill library (requires user approval)")对话里说一句「把 xx 流程装成技能」,智能体调install_skill,弹审批卡片——技能名、描述、正文、辅助文件清单全部列出来,你批了才落盘,默认自动启用到发起安装的智能体,下一轮生效。重复 ID、重名在弹卡前就拦截。配套的list_skills输出每个技能的启用智能体数,以及关键的「含你/不含你」标注:
// 同轮刚 install 过时 self 字段是旧的,启用状态以库里最新为准List<String>selfIds=SkillSupport.parseIds(agents.findById(self.getId()).map(Agent::getSkillIds).orElse(null));...if(s.global()){sb.append("(全局:所有智能体可用)");}elseif(s.agentCount()==0){sb.append("(尚未启用到任何智能体)");}else{sb.append("(已启用:").append(s.agentCount()).append(" 个智能体").append(selfIds.contains(s.skillId())?",含你)":",不含你)");}这个标注是被真实场景逼出来的:智能体看到「已启用 3 个智能体」,不知道名单里有没有自己,就会产生「大概有我吧」的错觉,接着调用一个自己根本没被启用的技能。只输出人数、再补一个「含你/不含你」,既不泄露全名单,又给了它判断所需的一切。注意第一行注释——self是构建工具时捕获的快照,同一轮刚装完就查询会拿到旧数据,所以每次现查库。
二、联网:两条工具,一个原则——只读免审批
取舍:不用 Skill 分发,API 直连
给智能体加搜索,社区的主流玩法是把搜索服务包成 Skill 发下去。我们没有走这条路,原因很实际:搜索是横切能力——单聊、群成员、编排者、被委派成员、微信后台轮、定时任务轮,每个构建智能体的地方都该有;Skill 注入是按智能体绑定走的,每个新链路都要补一遍装配逻辑。所以直接做成了框架级工具:WebTools无条件注册进所有 toolkit,进来就是全员标配。
@Tool(name="web_search",readOnly=true,concurrencySafe=true,description="Search the web via AnySearch and return ready-to-read Markdown results...")publicStringwebSearch(@ToolParam(name="query",required=true,...)Stringquery,@ToolParam(name="max_results",required=false,...)IntegermaxResults)@Tool(name="fetch_webpage",readOnly=true,concurrencySafe=true,description="Fetch a web page and extract its main text content...")publicStringfetchWebpage(@ToolParam(name="url",required=true,...)Stringurl,@ToolParam(name="max_chars",required=false,...)IntegermaxChars)readOnly = true意味着不进受控操作审批——搜个网页还要用户点「允许」,那这个能力就废了一半。风险面也收过:URL 只放行 http/https(file://、内网地址一律拒绝)、响应体 2MB 硬上限防内存爆、抓取失败返回中文说明让模型自己换路。
GBK 不乱码:一个字节流参数的差别
fetch_webpage的正文提取用 jsoup。第一版直觉写法是先把响应读成 String 再Jsoup.parse(str)——这就埋了个经典雷:编码在转 String 那一刻就定死了,HttpClient 拿不到响应头 charset 时默认 UTF-8,GBK/GB2312 老站点直接满屏乱码。改成一个参数的事:
// 必须从字节流解析:jsoup 自动识别 BOM 与 <meta charset>,GBK/GB2312 老站点不乱码Documentdoc=Jsoup.parse(newByteArrayInputStream(body),null,uri.toString());从字节流解析时,jsoup 会自己找 BOM 和<meta charset>,老站点稳了。
正文提取:先降噪,再找主体
整页body().text()会把导航、页脚、广告文案全喂给模型,浪费上下文还干扰判断。提取分三步:先把脚本类噪声整组移除,再优先命中语义化容器(article/main/[role=main]/#content),按标题、段落、列表项逐块拼接保结构;块太少(不足 200 字符)说明选择器没命中,退化为整页 text 兜底:
/** 正文提取:剔除脚本/导航等噪声后,优先语义化容器按块拼接;块太少退化为整体 text() */privateStringextractText(Documentdoc){doc.select("script,style,noscript,svg,iframe,canvas,nav,footer,header,aside,form,button,"+"select,input,textarea,link,meta").remove();Elementroot=doc.body()!=null?doc.body():doc;Elementmain=root.selectFirst("article, main, [role=main], #content, .content");if(main!=null)root=main;StringBuildersb=newStringBuilder();if(!title.isEmpty())sb.append("# ").append(title).append("\n\n");Elementsblocks=root.select("h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,td,dd,dt");Stringlast=null;for(Elementb:blocks){Stringt=b.text().trim();if(t.isEmpty()||t.equals(last))continue;// 相邻去重last=t;sb.append(t).append('\n');}Stringstructured=sb.toString().trim();if(structured.length()<200){Stringplain=root.text().trim();if(plain.length()>structured.length())returnplain;}returnstructured;}查了什么,摆给你看
联网工具最容易翻车的体验是黑箱:智能体半天没动静,你不知道它在搜还是在想。所以每次工具执行都有两路可视化——SSEweb_activity事件实时推「正在搜索 xxx / 正在抓取 xxx」提示条;搜索与抓取的完整明细(关键词、站点、网址、页面标题、失败原因)作为 JSON 随发言段落落进messages.web_activity(V15 迁移),气泡下方「联网记录」折叠条随时展开回看,历史会话也能查。
三、思考过程:推理模型在想什么,逐字看得见
起点:「他是不是卡了?」
推理模型回答前会先「想」一阵,API 层面就是流式 delta 里多出来的reasoning_content字段。问题是这阵「想」经常长达几十秒——界面上只有一个打字点在跳,用户的直觉是「卡了,重启吧」。抱怨模型慢没用,得把「想」的过程本身摆出来。
调研:框架其实全链路都支持
先别急着造轮子,把 AgentScope 的字节码翻了一遍,结论是框架早就铺好了路:
OpenAIMessage ← delta.reasoning_content(DeepSeek/GLM/Qwen 系) delta.reasoning(别名)、reasoning_details(OpenRouter 系) OpenAIResponseParser ← 流式与非流式路径都把它们解析成 ThinkingBlock ReActAgent ← ModelCallBlockLifecycle 把 ThinkingBlock 转成 THINKING_BLOCK_START / DELTA / END 事件,无条件发射关键在「无条件」三个字:StreamOptions.includeReasoningChunk这个开关只管旧版stream()API,我们用的streamEvents()不受它管——思考事件本来就在往外发,只是没人接。所以接入工作只剩「接住」:三处订阅点(单聊、编排者轮、被委派成员轮)各加一个THINKING_BLOCK_DELTA分支,转发成 SSEthinking_delta。
一个字段解决路由:messageId 在不在
思考发生的时机分两种:正文气泡还没开(模型还在想,第一句话没吐),和气泡已经开了(协作成员被委派后边执行边想)。前端对应两个展示位——会话级的「思考条」和成员气泡内的思考框。不想加新事件类型,就让 payload 里的messageId自己说话:气泡没开就不带messageId→ 前端进思考条;气泡开了就带 → 进气泡内思考框。
{"agentId":"...","conversationId":"...","delta":"用户想要…"}{"agentId":"...","conversationId":"...","messageId":"m-123","delta":"先检查…"}两次体验返工
第一版上线自己人先不满意:折叠条放在气泡下方,和「联网记录」条挤在一起,视觉上分不清谁是谁——挪到气泡上方,群聊有名字时正好卡在名字和气泡之间。第二版更隐蔽:思考结束后折叠条不出现,要等正文全部流完才冒出来——思考快的模型看起来像没思考过。改成「思考一结束立即把已积累的思考文本转进刚打开的气泡」,reply_end时再用服务端的权威全文覆盖一次。现在的节奏是:思考条逐字滚动 → 正文第一个字出现、折叠条同时就位 → 正文流完。
落库:要留痕,但别污染
思考全文值得留(回看、导出都是刚需),但绝不能进对话上下文——推理模型的思考动辄几千字,历史注入把这些也拼进去,上下文预算直接爆炸,思考里的半成品结论还会干扰后续轮次。所以单独开一列:messages.thinking(V16 迁移),历史注入只拼content,两个世界互不过问。逐段落库时每段发言携带它前面的思考,单条超 2 万字符截断;导出聊天记录时以引用块一并带出;普通模型没有思考输出,全链路显示不受影响。
实测还有个惊喜:我们自己的编排者(项目经理)绑的就是推理模型,单轮吐了 868 条思考增量——这功能对「看他在想什么」来说不是锦上添花,是雪中送炭。
四、翻车排查一:一轮装四个技能,只绑定了一个
现象
测试「智能体一次安装四个技能」:四个install_skill调用全部成功返回,结果第一个技能绑定到了智能体,后三个全部「安装成功但没绑定任何人」。稳定复现,且总是第一个赢。
排查:并发执行 + 读改写覆盖
先怀疑前端,排除;再怀疑后端绑定逻辑,单测全过。直到把四个调用的时间线摆出来才醒悟:它们是同一轮里并行的四个工具调用。AgentScope 的工具执行器对标记了并发安全的工具会并行跑——而install_skill的绑定路径是一段标准的读改写:
读 agents.skill_ids(CSV) → 追加新技能 ID → 写回四个并行调用都从同一个旧值出发,各写各的,最后落盘的那个把前面几个的写入全部覆盖——「总是第一个赢」的表象下,是最后完成的那个赢,恰好它总是带着最早的快照。
求证:去字节码里看框架怎么调度
猜想需要证据。AgentScope 的@Tool注解用javap反编译看默认值:
public @interface Tool { java.lang.String name(); java.lang.String description(); boolean readOnly() default false; boolean concurrencySafe() default true; // ← 默认并行 }concurrencySafe默认true。再看ToolExecutor的调度逻辑:并发安全的工具进Flux.mergeSequential并行槽,不安全的进Flux.concat串行槽。结论清晰了——这是框架留给「有状态写操作」的开关,我们没拨。
修复:写操作全部串行
一行注解的事,但要划对边界:所有「读改写共享状态」的工具都该串行——install_skill、编排者的create_team、add_member;而delegate(成员执行任务)虽然也是写(写消息),但各写各的会话、无共享读改写,保持并行——成员一轮跑几分钟,串行会把协作拖成接力赛。
@Tool(name="install_skill",concurrencySafe=false,...)// 绑定是读改写,必须串行@Tool(name="create_team",concurrencySafe=false,...)// 团队快照同理@Tool(name="add_member",concurrencySafe=false,...)// 同上@Tool(name="delegate",...)// 各写各的,保持并行这期的教训值得记一笔:引入并发能力的框架时,「默认并行」的每一个写操作都要过一遍脑子——单工具的原子性它不保证,跨工具的读改写更是完全交给你。
五、翻车排查二:长任务总在恰好 5 分钟被掐,还谎称「用户终止」
现象
协作任务里给成员派个跑几分钟脚本的活,总在恰好 5 分钟整被掐断,报错文案还是「协作已被用户终止」——可没人点过停止。把时间一拉:不是大概 5 分钟,是精确 300 秒。精确到这个地步,就不再是业务代码的锅,是框架里有个写死的数字。
排查:delegate 的特殊之处
顺着「谁会在 300 秒动手」查:delegate的实现是把成员的整个回复轮跑在一次工具调用里——成员轮要流式转发、要落库、可能还有多轮 ReAct,全都在delegate这个工具的执行窗口内完成。而框架对工具执行有一个默认的总时长上限:5 分钟。时间一到,工具执行被取消,异常顺着链路往上抛。
真正的坑在异常处理:取消产生的中断信号在异常链里,和我们主动「用户停止」走的中断路径长得像,先被判定成了「用户终止」——于是用户看到一句与事实相反的甩锅文案。更隐蔽的是成员自己跑终端命令也一样中招:超 5 分钟的命令同样死在这个默认值上。
修复:放开总上限,守住空闲下限,判定只认标志
三件事:
1. 工具执行与模型传输层的总上限:5 分钟 → 120 分钟 (协作限制里本来就有一分钟级的整体/单成员超时配置,业务说了算) 2. 保留两个「死连接」兜底:流中途静默 5 分钟、模型 3 分钟无输出,仍然掐断 3. 「协作已被用户终止」的判定收紧为只认用户停止标志, 框架异常从此不可能冒充用户终止;超时明确标注「执行超时,已中止」一是总上限放开——框架默认的 5 分钟比业务侧可配置的协作超时还紧,属于越俎代庖,长任务交给我们自己的超时体系管;二是不能全放开,流静默与无输出的兜底留着,真死连接照样掐,只是不再误伤慢任务;三是判定收紧,顺带把失败路径的日志补全了异常链,排障不用再猜。单聊回复超时的口径也一并对齐。
上一场排查是「默认并行」,这一场是「默认 5 分钟」——框架替你做的每个默认决定,都值得翻开来看看。
六、委派任务卡:从消息流退到日志里
现象:群里最吵的是编排者
编排协作的设计是「全程透明」:编排者每段发言、成员完整输出都是真实消息。但透明过了头——每次委派,群里先刷一张「【委派任务卡 → 成员】」的大卡片(完整任务描述原文),接着编排者复述一遍安排,成员再输出。三屏内容里有一屏是重复的,长任务连着委派五六轮,任务卡把真正的产出挤得找不着。
方案:档案归档案,对话归对话
任务卡从消息流里拿掉,完整原文改记运行日志(coordination类型,日志页点击展开全文),超 2000 字符截断防日志膨胀:
Conversationconv=target.get();appLogs.record(AppLog.TYPE_COORDINATION,conv.getId(),targetAgent.getId(),"编排者「"+orchestrator.getName()+"」委派任务给「"+targetAgent.getName()+"」\n"+taskCard(task));/** 任务卡原文记日志用:超长截断,防日志膨胀 */privateStringtaskCard(Stringtask){Stringt=task==null?"":task.trim();if(t.length()>2000){t=t.substring(0,2000)+"\n…(任务卡过长已截断)";}returnt;}拿掉卡片不等于拿掉信息——编排者的系统提示同步改了一条:委派前后要用简短的自然语言说明把什么任务委派给了谁、为什么这样安排,不要复述成员的完整输出。群里留下的叙事链是「我打算让测试来做这个,因为她写过这类用例 →(成员输出)→ 已完成,结论是 xx」——人话讲安排,原文进档案。
顺带把所有编排协作日志(委派/终止/拉人/纠错重试)都带上了编排者名字,多编排者场景不再「查无此人」。
【配图位置 4:协作群消息流(自然语言安排 + 成员输出,无任务卡)与运行日志中的完整任务卡】
七、交互收口:折叠、分割线、图标与几个防坑
功能密度上来之后,界面也要跟着理。这期的杂项清一波:
设置弹窗手风琴折叠。设置项涨到七块,一屏铺开找不到北。现在沙箱、语音识别、联网搜索、协作超时、上下文压缩默认收起、点击标题展开,系统设置默认展开,同时只展开一个(手风琴:点开新的,旧的自动收),分区间加分割线;数据管理与关于保持常显。
空状态图标统一。技能页空状态只有一行字、没图标;联系人页的空状态图标居然是块「芯片」——和模型页撞了车。逐页对齐侧栏导航:技能页补书本图标、联系人页改双人图标、消息页换成与侧栏同形的圆润气泡,所有空状态图标统一 64px、透明度 0.4、圆角描边。
弹出层防误关。所有弹出层与模态框改为「按下 + 松开都在遮罩外面」才关闭——按住往外拖选文字、松手时鼠标恰好在遮罩上,以前会直接把弹层关掉,白填的表单说没就没:
// 记下按下与松开的位置,都落在遮罩上才算主动关闭@pointerdown="maskDown = $event.target === $event.currentTarget"@pointerup="maskUp = $event.target === $event.currentTarget"@click.self="maskDown && maskUp && emit('close')"输入框拉伸。聊天输入框上边框可拖拽调高,双击复位——长文本粘贴党不用再滚小窗。
技能辅助文件整理。GitHub 整仓导入的技能,辅助文件清单一水儿的.git元数据,全部过滤;文件多时列表限高内部滚动,不再把详情卡片撑出屏幕。
切页不丢动画。定时任务/微信后台回合的「谁在思考」预告(reply_pending)此前不进回合快照,切走再切回就补不回来了——纳入快照、重连补发,思考指示条不再凭空消失。
错误日志补全。之前错误日志只记用户看到的简短提示,排障时等于没记。现在统一叠加完整报错细节(异常类型、消息与根因链,超时类另记上限分钟数),用户侧提示保持简短不变——日志给人查,提示给人看,两边各说各的话。
群聊菜单防撑破。群聊「⋯」菜单里成员名字过长会把菜单撑破宽度——成员网格改固定四列等宽,超长名字省略号截断,悬停可看全名。
写在最后
上期结尾说「功能往回收一收,下一个大件才好往上放」——这期的大件是技能系统:智能体的能力边界第一次开始由他自己扩张,你不用预判他缺什么,他缺什么自然会装(当然,装之前得你点头)。联网与思考透出是这个大件的两翼:一个让他自己找答案,一个让你看他怎么想。它们指向同一个方向:智能体的黑箱,正在被一块块打开。想什么,逐字看得见;查什么,记录留得住;装什么,卡片报给你批;派什么活,人话讲给你听。
能力自生长的前提是可监督,可监督的尽头是连想法都摆在明面上。配套的兜底也在:思考全文落库但绝不进上下文,任务卡进日志但编排者必须人话转述,报错文案简短但异常链全量入日志——透明不等于倾倒,每一层都留了边界。
两场排查也值得回味:一场挖到框架的并发模型,一场挖到框架的默认超时。引入框架省下的时间,终究要在字节码里还回去——还好,都还得起。
项目完全开源,欢迎 Star、Fork、提 Issue:
- 桌面壳(含前端 web/):agent-team-desktop
- 后端仓库:agent-team-server
如果你对智能体自助技能扩展、AgentScope 思考流透出、工具并发模型、框架默认超时的坑、jsoup 正文提取这些话题感兴趣,欢迎在评论区交流。
如果这篇文章对你有启发,点个赞 + 收藏再走呗~
#人工智能 #ai #多智能体 #springboot #electron #vue3