心中的海边城——SonettoHereAgent异步工具库设计全解

SonettoHereAgent异步工具库设计全解

Posted by Miso on September 4, 2026

0. 前言

我已经忘记了我是什么时候开始学习Agent的了,似乎是在今年四月?我只记得工具(Tools)绝对是我接触的第一个Agent概念,真的没有之一。

与其它许多Agent一样,Sonetto的能力是靠一整套精心规划的工具集撑起的。不同Agent对于工具集这种基础设施的理解非常不同。PiAgent只有4个极简的工具作为工具集,而Hermes Agent有堂堂86个。

四个和八十六个工具的差异会是84/4=21倍吗?还是没有那么多?工具集会以什么样的形式影响Agent的能力边界?为什么庞大的工具集未必能带来更好的Agent表现?我会尝试告诉你。

本文以LangGraph@Python作为后端开发框架。在讨论脚本工具时,默认指Python脚本工具。

1. 关于工具

工具的本质是一个Python函数,拥有固定的输入和输出格式。LLM在ReAct环的Act阶段输出该函数的入参,函数的返回值回到LLM的上下文,以此获取工具的信息完成一次工具调用。

我们以Sonetto假期工具为例来看看吧。这个工具是对一个UAPI的薄封装,很适合用来研究一下工具的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@get_doc  
@background  
class HolidayCalendarTool(BaseTool):  
    name: str = "holiday_calendar"  
    description: str = (  
        "查询指定日期/月份/年份的万年历与节假日信息。"  
        "支持农历、节气、法定节假日。[调用积极性: 可自由看情况调用] [get_doc: 仅在发生错误时 get_doc]"    )  
    args_schema: type[BaseModel] = HolidayCalendarInput  
  
    def _run(  
        self,  
        date: str = "",  
        month: str = "",  
        year: str = "",  
        timezone: str = "Asia/Shanghai",  
        holiday_type: str = "all",  
        include_nearby: bool = False,  
        nearby_limit: int = 7,  
    ) -> str:  
        if not date and not month and not year:  
            return format_error("必须提供 date、month 或 year 参数之一")  
  
        try:  
            result = self.client.uapi.misc.get_misc_holiday_calendar(  
                date=date,  
                month=month,  
                year=year,  
                timezone=timezone,  
                holiday_type=holiday_type,  
                include_nearby=include_nearby,  
                nearby_limit=nearby_limit,  
                exclude_past=True,  
            )
            return format_success(result)  
        except Exception as e:  
            return format_error(f"节假日查询失败: {e}")

继承类原作ToolBase,是对BaseTool的功能添加,有修改

我们可以看到,一个工具由四个部分组成。

  • 工具名。模型据此来选择工具进行调用。
  • 工具描述。模型据此决定何时使用该工具。
  • 入参格式。与业务函数的入参完全相同,使用pydantic进行描述
  • 业务函数。负责生成返回值。

我们只要为继承了BaseTool的工具类实现上述四个部分,LangGraph就会自动帮我们完成入参格式与工具名提示词注入、FuncCall入参分析、返回值注入等等鸡毛蒜皮的小事。

2. 工具的涌现

对于几个工具,我们以A、B、C为例。如果A+B的组合可以让LLM获取到等价于C的信息,我们就说C这个工具被A和B涌现了,写作A+B=>C。比如说,Sonetto使用她的Tavily搜索工具Ts找到了一个天气网站,再用Tavily提取Te提取了天气内容,那么她便得到了与调用天气工具W相等价的信息,也就是Ts+Te => W。

Python脚本工具P是SonettoHere最重要的工具。它提供了极其强大的工具涌现能力。这是Skill体系的基础。假设天气获取技能S_W在SKILL.md上面记载着公开的天气API,甚至在Skill的脚本部提供了访问此API的方便函数,Agent就可以使用脚本工具访问此API来获得天气内容。此时P+S_W => W。

那么,我们为什么要为Agent提供被涌现掉的工具呢?

第一个原因是人在回路与信息传出的效率。为了提供人类协作方式,Sonetto通过ReAct中插入用户消息或打断输出的方式提供了人在回路的信息入口。那么与此相对地,我们也需要人在回路的信息出口,让信息从Agent流向人类,使得人类更有的放矢地调节Agent的推进行为。这个信息出口就是Sonetto v1时代森罗计划的内容。这个计划需要为工具提供一个精心设计的前端气泡,使得人类能够超高效地读取到工具调用的结果。

第二个原因是稳定性与ReAct推进效率。很难说Sonetto在Ts没有找到公开的天气API接口的时候后续会怎么做。封装出一个工具能极大地降低Agent出错的可能,同时降低token消耗。

3. Sonetto的工具集

Sonetto的工具集分为多个向外推进的环形。越往外环,工具越可被涌现。

零环 一切的一切

编号 工具名 描述
T0-01 run_python 在隔离环境中执行 Python 代码,返回 stdout 输出。用于计算、数据处理、文本转换。

我们选用了Python作为Sonetto的脚本工具,因为Sonetto的后端就是Python,况且许多Skill的脚本是用Python写的。

如果Sonetto没有脚本工具,几乎不可能通过其它工具把它涌现出来。这就是一切的一切。

一环 由于特殊原因不可涌现的工具

编号 工具名 描述
T1-01 call_sub_agent 创建一个子 Agent 会话执行单轮任务并将结果返回。用于需要独立的推理和工具调用的子任务,例如分析代码文件、执行多步骤搜索等。子 Agent 拥有独立的上下文窗口,不会污染主对话。
T1-02 ask_user_single_choice 向用户提供多个选项,用户从中选择一项。用于需要用户做选择的场景。
T1-03 ask_user_multi_choice 向用户提供多个选项,用户可勾选多项。用于需要用户做多选的场景。
T1-04 ask_user_qa 向用户提问并等待文字回复。用于需要用户自由输入信息的场景。
T1-05 list_background 列出当前全部后台运行中的任务(配合支持 background 参数的工具使用)。返回每个任务的索引、来源工具、状态(running/completed/failed)与耗时。需要取回某个任务的真实结果时,用 await_background 工具传入其索引。
T1-06 await_background 等待一个后台运行的任务并取回其真实结果。配合支持 background 参数的工具使用:先以 background=true 调用获得任务索引,再用本工具传入该索引等待结果完成。任务尚未完成且超过 timeout_seconds 时返回 running 状态(不无限阻塞),可稍后再次调用。不确定有哪些后台任务时,先用 list_background 查看。
T1-07 task_tracker 无状态任务清单追踪。每次传入完整的 todos 列表,工具返回统计摘要。

子智能体。这个Sonetto进行复杂大上下文任务处理的工具涉及复杂的前端和后端编排,不能由Python进行涌现。

同理,依赖于复杂前后端异步通信的用户交互系列工具(单选、多选、输入)也不可涌现。

以及后台任务相关工具(list、await)这种涉及本工程内部后端编排的。

还有前端任务显示这种涉及前端通信的。

二环 难以涌现的工具

编号 工具名 描述
T2-01 tavily_search 使用 Tavily 执行网络搜索,返回结构化结果(标题、URL、摘要、相关度分数)。支持多级搜索深度(ultra-fast/fast/basic/advanced)、时间范围筛选、域名白名单/黑名单、AI 摘要回答、全文内容提取。Agent 应先用此工具搜索发现相关链接,再用 tavily_extract 深入阅读内容。
T2-02 tavily_extract 从指定 URL 提取网页内容,返回 Markdown 格式的正文。支持一次最多 20 个 URL,支持定向提取(query 参数筛选相关片段)。JS 渲染页面请使用 advanced 深度。Agent 应先用 tavily_search 发现相关链接,再用此工具深入阅读内容。

你总不能指望Sonetto仅凭LLM训练语料就复诵出一个能用的公开搜索API来,所以网络搜索工具是必要的。借助Tavily网络搜索工具,大量外源信息流入上下文,工具的涌现性将达到极大增强。一并封装了个提取工具。

三环 出于特殊原因而封装的可涌现工具

编号 工具名 描述
T3-01 file_read 读取文件内容并返回完整文本。仅支持 UTF-8 编码的文本文件,非 UTF-8 文件(如二进制、GBK 编码)会返回错误提示。
T3-02 file_write 写入内容到文件(自动创建父目录)。执行前需用户确认放行。
T3-03 file_edit 对文件进行多笔精确字符串替换(单笔也传入数组)。edits 为 JSON 数组,old_string 必须与文件内容完全一致(含空白、缩进),不唯一时需设置 replace_all。仅支持 UTF-8 编码的文本文件。
T3-04 file_delete 删除文件或目录(文件用 os.remove,目录递归删除)。执行前需用户确认放行。
T3-05 file_rename 重命名或移动文件/目录到新路径。执行前需用户确认放行。
T3-06 file_create_directory 创建目录(自动创建所有必要的父目录,已存在时不做操作)。执行前需用户确认放行。
T3-07 file_list_directory 列出目录内容(文件与子目录,含大小,目录优先排序)。
T3-08 file_search 使用 glob 通配符在目录中搜索文件。
T3-09 file_search_text 在单个文件内容中进行正则文本搜索,返回每处匹配的行号、列号与匹配文本。
T3-10 analyze_image 使用智谱 GLM-5V-Turbo 多模态模型理解图片内容。支持本地文件(local:path)和网络图片(url:https://…)。可指定 prompt 提问,如 ‘这张图里有什么文字?’。
T3-11 read_image 读取本地图片文件,将图片内容注入 LLM 上下文,使你可以直接看到并分析该图片。当用户提到本地图片文件时调用此工具,而非 analyze_image(后者使用外部模型分析,适用于 URL 图片)。仅接受本地文件路径,不支持 URL。

从三环开始,工具都可以被涌现出来了。这些工具是出于人在回路和安全原因才封装的。

所有文件操作工具都能被Python涌现覆盖,但我们还是封装了它们。原因是侵彻性的文件操作必须经由人在回路进行确认,这依赖于这些操作有一个可监视的入口。使用Python操作文件就很难从静态代码分析出来这是在对文件进行什么操作了。所以Sonetto封装了一整套文件处理工具集。

委托/自理解两个图像理解工具。需要做一点网安性质的路径校验,所以没让Sonetto用Python来搞。

四环 Sonetto高级业务工具

编号 工具名 描述
T4-01 geocode_address 将详细地址转换为经纬度坐标(GCJ-02 火星坐标系)。返回坐标可直接用于 nearby_search / get_transit_route / get_cycling_route。
T4-02 fuzzy_address_search 通过关键字模糊搜索地点/POI。返回名称、地址、坐标等信息。建议指定 city 提高准确率。
T4-03 nearby_search 在指定坐标附近搜索兴趣点(POI)。支持关键字/类型筛选和半径设置。
T4-04 get_transit_route 查询公交/地铁公共交通路线规划。返回多方案,含费用、耗时、步行距离、换乘详情。
T4-05 get_cycling_route 查询骑行路线规划。返回距离、耗时、逐段导航指令和道路名称。
T4-06 todo_add 向 Todoist 添加新任务。
T4-07 todo_list 列出 Todoist 中所有未完成任务,支持按项目、分区、标签等条件筛选。
T4-08 todo_query 根据 task_id 查询 Todoist 中单个任务的详细信息。查不到时用 todo_list 列出全部任务。
T4-09 todo_update 更新 Todoist 中现有任务的内容、截止日期、优先级、描述、标签、指派等属性,或将任务移动到其他项目/分区。
T4-10 todo_complete 将 Todoist 中指定任务标记为已完成。需要提供 task_id。
T4-11 todo_uncomplete 将 Todoist 中已完成的任务重新打开。需要提供 task_id。
T4-12 todo_delete 从 Todoist 删除指定任务。需要提供 task_id。
T4-13 todo_list_projects 列出 Todoist 中所有项目及其详细信息。添加任务前建议先调用以确认项目名。
T4-14 todo_list_sections 列出 Todoist 中的分区(section),可按项目筛选。添加或移动任务到特定分区前建议先调用此工具确认分区名。
T4-15 todo_list_labels 列出 Todoist 中所有标签。添加任务前可通过此工具确认可用的标签名。
T4-16 get_current_weather 获取指定城市的天气信息。支持实时天气、多天预报、逐小时预报、分钟级降水、生活指数。city 和 adcode 二选一即可。
T4-17 holiday_calendar 查询指定日期/月份/年份的万年历与节假日信息。支持农历、节气、法定节假日。

本环的工具全部可以由Python结合Skill涌现覆盖。它们是出于稳定性与ReAct推进效率才封装的。

包括全部高德地图工具和Todoist系列工具。你可以从这两个系列工具看出来Sonetto规划生活的Agent特色。

以及天气和假日工具。

五环 彩蛋性质工具

编号 工具名 描述
T5-01 tarot 韦特塔罗牌占卜(RWS)。支持单牌/三牌/凯尔特十字三种牌阵。仅供娱乐参考。

塔罗牌。这个工具从里到外都应该使用Skill实现。它实在是太Skill了,有Reference,有Script,最好还要有SKILL.md教Sonetto解牌。但是我觉得封装成工具看前端气泡可以增强沉浸感,就这么干了。

4. 工具装饰器框架的代码设计

对于所有Sonetto的工具,我们发现以下三个功能具备跨工具的特点,每个工具都可能取得三个功能中的一个或多个:

  • 人在回路确认气泡。在前端弹出气泡展示该工具的入参,等待用户的审批。用户有自由输入信道,在拒绝时可以将任意文本发送给Agent。
  • 读取工具辅助文档。添加get_doc: bool入参,读取与该工具相同目录下的TOOL.md获取使用方式指导。
  • 背景执行。为工具添加background: bool入参,允许工具进入后台执行并返回取得结果的令牌。使用await提交令牌并进行可能的阻塞等待以取得实际返回值。

这三个功能站在架构师的视角上有不同的实现方案。我是这么想的,如果使用继承,我需要为BaseTool建立三个一级子类,然后三个一级子类可以交织出来8个不同的二级子类,如何保证这些逻辑全部正确地进入_arun从而被LangGraph框架调用是很困难的。

所以最后我选择了基于传统装饰器组合设计模式的面向切面编程(AOP)。使用Python程序员喜闻乐见的装饰器对Tool类进行修改,在不显式持有业务类的情况下劫持_arun函数和入参args_schema来注入相关的逻辑。

1
2
3
4
5
6
7
8
9
10
@get_doc  
@background  
class HolidayCalendarTool(ToolBase):  
    name: str = "holiday_calendar"  
    description: str = (  
        "查询指定日期/月份/年份的万年历与节假日信息。"  
        "支持农历、节气、法定节假日。[调用积极性: 可自由看情况调用] [get_doc: 仅在发生错误时 get_doc]"    )  
    args_schema: type[BaseModel] = HolidayCalendarInput  
  
    async def _arun(
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_doc(cls: ToolClass) -> ToolClass:  
    """应用「按需返回领域文档」类装饰器。  
  
    Args:        cls: 目标工具类。  
    Returns:        增强后的 pydantic 子类。    """  
    def make_wrapper(orig: Callable[..., Any]) -> Callable[..., Any]:  
        @functools.wraps(orig)  
        async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:  
            if kwargs.pop("get_doc", False):  
                # TOOL.md 磁盘读取离环,避免卡事件循环  
                return await off_thread(self._load_doc)  
            return await orig(self, *args, **kwargs)  
  
        return async_wrapper  
  
    return enrich_tool_class(  
        cls,  
        schema_fields={  
            "get_doc": (bool, Field(default=False, description=DEFAULT_DESCRIPTION)),  
        },
        wrap_method=make_wrapper,  
    )

此框架未来可以扩展更多的功能。

5. 结尾

结尾部分草稿使用Agent写作 LLM z-ai/glm-5.3-flash HARNESS SonettoHere v3.6.0-preview 由人类逐段修改。

这就是SonettoHere工具集的全貌。这篇文章讲了三件事,工具是什么,工具如何相互涌现,以及我们如何用五个环和三个装饰器把如此多的工具交付给LLM。

从四月到现在,Sonetto 的工具集经历过好几次推倒重来。最早的时候我们还不知道何为涌现和Skill,犯过许多堆砌工具的毛病,把能想到的能力全部封装成工具交给Sonetto,结果LLM在长长的工具列表里陷入无法逃离的迷茫。后来才我们慢慢想明白,工具集的内在结构比规模更重要。不可涌现的工具留在核心,可以涌现的工具推离出去,直至环状的边缘而消失(点名编程系列工具)。五层环状的工具集就是这个想法落地的现状。

心中的海边城不在任何现实的地图上,它只存在于 Sonetto 的上下文窗口里。这座城市还在施工。装饰器框架预留了扩展点,五环之外或许还有新的环,旧工具也会被涌现、被拆掉、被重建。但只要现实的需要还在,这座海边城就会一直生长下去。

事实证明z-ai/glm-5.3-flash并不太适合文本写作。