数据截至 (上游 commit 2e970421f2b0)
工具与 activity 机制:@activity 装饰器如何变成 LLM 可调的 schema
30 秒导读: LLM 只会输出文本,它没法直接"调用一个 Python 方法"。Griptape 的做法是:你在方法上贴一个
@activity装饰器,框架就自动把这个方法翻译成一段 LLM 看得懂的 JSON schema(方法能干嘛、要什么参数);LLM 照着 schema 生成一次调用意图,框架再把它 容错地 打回到那个真实方法上执行。本章讲清这条"方法 → schema → 调用 → 结果"的翻译链路。
本章覆盖三块源码:
| 文件 | 角色一句话 |
|---|---|
griptape/utils/decorators.py | @activity 装饰器本身:给方法打标记、挂配置 |
griptape/mixins/activity_mixin.py | 收集带标记的方法、渲染 schema、校验入参 |
griptape/tools/base_tool.py | 把多个 activity 拼成整个 Tool 的 schema、执行、容错、转存记忆 |
边界: LLM 输出的那段动作文本 怎么被解析成一次调用(ReAct/actions 子任务)属于 02;结果转存进的 Task Memory 内部怎么存 属于 05。本章只管"一个方法怎么暴露成工具动作、怎么被容错执行"。
1. 这是什么(零基础也能懂)
先理清两个词:Tool 和 Activity
Griptape 里工具是 两层 的,别混:
- Tool(工具) = 一个 Python 类,一个"技能包"。比如"计算器工具""网页抓取工具"。
- Activity(活动) = 工具类里 具体能做的一个动作,就是一个被
@activity装饰的方法。比如计算器里的calculate。
一个 Tool 可以有多个 Activity(一个"文件工具"可能有"读文件""写文件""列目录"三个动作)。LLM 面对的最小单位是 Activity,不是 Tool。
它解决什么问题
模型缺的是"手脚"。你想让 LLM 帮你算 (3+4)*5,但模型自己算数会错;正确做法是让它 调用真实的 Python 计算。难点不在"让模型说想算什么",而在:
- 模型只会吐文本,怎么让它知道"有个 calculate 动作,要传一个叫 expression 的字符串"?
- 模型吐回来的调用意图(一段 JSON),怎么 安全地 落到
calculate这个真实方法上,还不能因为它多传/少传参数就崩?
@activity + ActivityMixin + BaseTool 三件套就是干这个的。
用起来什么样(一个真实工具)
这是仓库里自带的计算器工具,是理解全章的锚:
# griptape/tools/calculator/tool.py — 真实源码(节选)
class CalculatorTool(BaseTool):
@activity(
config={
"description": "Can be used for computing simple numerical or algebraic calculations in Python",
"schema": Schema({
Literal("expression", description="Arithmetic expression parsable in pure Python..."): str,
}),
},
)
def calculate(self, params: dict) -> BaseArtifact:
expression = params["values"]["expression"]
return TextArtifact(numexpr.evaluate(expression))
你只写了两样东西:一句 description(告诉 LLM 这动作干嘛)、一个 schema(告诉 LLM 参数长啥样)。剩下的翻译、暴露、容错执行,全是框架自动做的。 本章就是拆开这个"自动"。
一句话直觉
把
@activity想成给方法贴 产品说明书:说明书(description + schema)是给 LLM 这个"顾客"看的;顾客照说明书下单(生成调用),框架照订单去仓库(真实方法)取货,取回来的东西还要 统一装箱(转成 Artifact)才交付。
2. 顶层全景(一次工具调用怎么转)
先看整条链路。怎么读这张图: 上半段是"启动时一次性做的翻译"(方法 → schema),下半段是"每次调用时做的分发"(schema → 执行)。中间的虚线是 LLM。
【启动 / 组装 prompt 时:方法 → schema】
方法 def calculate ActivityMixin BaseTool
┌──────────────┐ 收集 ┌──────────────┐ 拼装 ┌──────────────┐
│ @activity 打标 │ ───────▶ │ activities() │ ──────▶ │ schema() │
│ is_activity │ │ 按名单过滤 │ │ = 多动作合一 │
│ + config │ │ 渲染 desc/schm│ │ 的 JSON schema │
└──────────────┘ └──────────────┘ └───────┬──────┘
│ 交给 LLM
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -│- - - - - -
▼
【运行时:schema → 调用 → 结果】 LLM 产出一次动作
┌──────────────┐ {"values":input} ┌──────────────┐ (name/path/input)
│ 真实方法执行 │ ◀──── 包装 ────── │ BaseTool.run │ ◀──────────────
│ 返回任意值 │ ───── 兜底 ─────▶ │ before/try/after│
└────── ────────┘ 转成 Artifact └───────┬──────┘
│ off_prompt?
▼ 转存 Task Memory([05])
各部件职责:
| 部件 | 干什么 | 依据 |
|---|---|---|
@activity 装饰器 | 给方法挂 is_activity=True、name、config,并包一层参数解包 | utils/decorators.py:31-50 |
ActivityMixin.activities() | 用 inspect.getmembers 扫出所有带标记的方法,按 allow/deny 名单过滤 | mixins/activity_mixin.py:55-65 |
ActivityMixin 渲染族 | activity_description / activity_schema 把 config 渲染成描述和参数 schema | mixins/activity_mixin.py:79-102 |
ActivityMixin.validate_activity_schema | 用 schema 或 pydantic 校验 LLM 传来的入参 | mixins/activity_mixin.py:116-123 |
BaseTool.schema() | 把该 Tool 所有 activity 的 schema 合成一个"或"结构,输出 JSON schema | tools/base_tool.py:108-132 |
BaseTool.run() | before/try/after 三段执行,包装入参、兜底结果、转存记忆 | tools/base_tool.py:134-192 |
3. 核心原理(逐个机制,由浅入深)
3.1 @activity:给方法贴标签,不是改行为
要解决的小问题: 怎么在一堆普通方法里,认出"哪些是给 LLM 用的动作",并把"描述、参数 schema"这些元数据随身带着?
思路: 装饰器不改方法逻辑,只在方法对象上 挂几个属性。之后框架靠这几个属性来识别和读取元数据。
装饰器做了三件事(utils/decorators.py:31-50):
- 先校验 config。 传进来的
config字典必须过CONFIG_SCHEMA——description必填(str),schema可选、且必须是Schema/ 可调用 / pydanticBaseModel子类之一(utils/decorators.py:17-25)。没写 schema 就默认置None(utils/decorators.py:36-37)。 - 包一层 wrapper。 真实调用时框架传进来的是一个
params字典,wrapper 负责把它拆成关键字参数再喂给原方法(utils/decorators.py:41-42,细节见 3.4)。 - 挂三个标记:
name(默认取函数名)、config、is_activity=True(utils/decorators.py:44-46)。
关键细节: is_activity 这个布尔标记是整章的"暗号"——后面所有"这方法是不是一个动作"的判断,都是 getattr(method, "is_activity", False)。没这个标记的方法框架 看都不看。
3.2 activities():扫出动作,并按名单过滤
要解决的小问题: 一个 Tool 类里既有 activity 方法,也有普通辅助方法(run、validate…)。怎么只挑出前者?还想允许用户 临时禁用 某几个动作(比如给 LLM 一个只读的文件工具,不给"写")。
思路: 反射扫全部方法 + 双名单过滤。
真实实现(mixins/activity_mixin.py:55-65):
# 真实源码节选
for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
allowlist_condition = self.allowlist is None or name in self.allowlist
denylist_condition = self.denylist is None or name not in self.denylist
if getattr(method, "is_activity", False) and allowlist_condition and denylist_condition:
methods.append(method)
一句话:inspect.getmembers 拿到所有绑定方法,三个条件同时满足才算数——是 activity、在白名单(或没白名单)、不在黑名单。
名单的两个便捷开关(mixins/activity_mixin.py:45-51):
| 方法 | 效果 |
|---|---|
enable_activities() | 清空两个名单 = 放开全部动作 |
disable_activities() | allowlist=[] = 一个动作都不给(空白名单挡住所有) |
设名单时还会 预校验:名单里写的名字必须真的是个 activity,否则报错(_validate_tool_activity,mixins/activity_mixin.py:125-131)。
一个坑(源码注释直说):
activities()只能是普通方法、不能加@property,否则inspect.getmembers会触发最大递归深度错误(mixins/activity_mixin.py:53-54注释)。BaseTool.schema()同理(tools/base_tool.py:106-107注释)。
3.3 从 config 渲染出"给 LLM 看"的东西
要解决的小问题: LLM 需要两样:这动作 叫什么、干嘛用(自然语言),和它 收什么参数(结构化 schema)。这两样都要从 config 里"渲染"出来。
分三个渲染器,各管一摊:
| 渲染器 | 产出 | 巧妙处 | 依据 |
|---|---|---|---|
activity_name | 动作名 | 直接读 name 标记 | activity_mixin.py:74-77 |
activity_description | 描述文本 | 用 Jinja2 模板 渲染,{{ _self }} 指向工具实例——描述能动态引用工具的配置 | activity_mixin.py:79-82 |
activity_schema | 参数 schema | 支持 Schema 实例、可调用(传入 self 动态生成)、pydantic;还能被 extra_schema_properties 追加字段 | activity_mixin.py:84-102 |
activity_description 用 Jinja 是个不显眼但有用的设计:描述不是死字符串,可以写成模板,渲染时把工具实例注入进去,让"给 LLM 的说明"随工具配置变化(activity_mixin.py:82)。
3.4 schema():把多个动作拼成一份 JSON schema
要解决的小问题: 一个 Tool 有 N 个 activity,交给 LLM 时得是 一份 schema,让 LLM"从这 N 个动作里选一个来调"。
思路: 每个 activity 先各自生成一段 schema,再用 Or(...)(N 选一)合起来。
activity_schemas() 给每个动作生成的结构长这样(tools/base_tool.py:113-132):
# 真实源码节选:每个动作 → 一段 schema
schema_dict = {
Literal("name"): self.name, # 工具名(定死)
Literal("path", description=self.activity_description(...)): self.activity_name(...), # 动作名 + 描述
}
if activity_schema is None:
schema_dict[schema.Optional("input")] = {} # 无参动作:input 设为可选空字典
else:
schema_dict[Literal("input")] = activity_schema # 有参动作:input 用动作自己的 schema
两个值得记的点:
name/path/input三段式。 LLM 产出的一次调用意图就是这三个字段:哪个工具(name)、哪个动作(path)、什么参数(input)。- 无参动作也保留
input,只是设为Optional({})。 源码注释解释了原因:低端模型经常手贱传个空{},与其省略input让它出错,不如留着且设成可选(tools/base_tool.py:123-124)。
最后 schema() 用 Or(*self.activity_schemas()) 把所有动作合成"任选其一",再吐成 JSON schema(tools/base_tool.py:108-111)。
3.5 run():三段式 + 容错执 行
要解决的小问题: LLM 生成的调用未必规矩——参数可能缺、类型可能错、方法可能抛异常、返回值可能不是框架要的类型。执行环节必须 兜得住,否则一次工具调用就把整个 agent 循环搞崩了。
思路: before / try / after 三段,外面裹一层 try/except 把 任何 异常转成 ErrorArtifact(而不是抛出去)。
主流程(tools/base_tool.py:134-145):
run(activity, subtask, action)
│
├─ before_run → 取出 action.input,返回给下一段 (base_tool.py:147-150)
├─ try_run → 真正执行 + 结果兜底 (base_tool.py:152-175)
├─ after_run → 若配了 output_memory,转存结果 (base_tool.py:177-192)
└─ 任一段抛异常 → ErrorArtifact(str(e)) (base_tool.py:141-143)
try_run 里有两个关键约定(tools/base_tool.py:152-175):
第一,{"values": input} 包装。 框架调用真实方法时,不是直接把 LLM 给的 input 传进去,而是包一层:
# 真实源码:base_tool.py:163
activity_result = activity({"values": deepcopy(value) or {}})
为什么包这层?源码注释说清了:activity 的 wrapper 期望参数长成 {"values": <input>} 好解包成 kwargs;而 LLM 面向的 schema 里已经不含这层 wrapper 了,所以在分发时才补上(tools/base_tool.py:160-162)。这也解释了为什么 3.1 里 calculate 取值写的是 params["values"]["expression"]。
第二,非 Artifact 结果兜底成 InfoArtifact。 方法可以返回任意值,但框架下游只认 Artifact(制品,见 05)。所以:
| 方法返回了什么 | 兜成什么 | 依据 |
|---|---|---|
一个 BaseArtifact | 原样用 | base_tool.py:165-166 |
None | InfoArtifact("Tool returned an empty value") | base_tool.py:170-171 |
| 其它任意值 | InfoArtifact(值) + 打一条 warning | base_tool.py:167-173 |
3.6 wrapper 怎么把 {"values": ...} 解成关键字参数
要解决的小问题: 上面包了 {"values": input},但你的方法签名可能写成 def read(self, path, encoding) 而不是 def read(self, params)。框架怎么把字典里的值 对号入座 塞进不同签名?
这就是 @activity 那层 wrapper 调的 _build_kwargs(utils/decorators.py:73-101)干的活:
- 从
params["values"]里,只挑签名里出现的键 传进去(签名没有的多余键直接丢弃,容错)(utils/decorators.py:87)。 - 若方法签名里有
**kwargs,则全量透传(utils/decorators.py:81-83)。 - 若签名显式要
params或values,把原始字典/values 也补进去(utils/decorators.py:91-94)——这就是calculate(self, params)能拿到完整params的原因。 - 签名里有、但 LLM 没给的必填参数,补成
None(utils/decorators.py:97-99)——少传参不会因缺参报 TypeError,而是拿到None由方法自己处理。
一句话:签名怎么写都行,_build_kwargs 负责在"LLM 给的松散字典"和"方法的精确签名"之间做 容错对接。
3.7 入参校验:schema 或 pydantic 二选一
要解决的小问题: LLM 给的参数得先验一遍合不合格,再放行执行。
validate_activity_schema 根据 schema 类型分流(mixins/activity_mixin.py:116-123):
- 是
schema.Schema→ 调.validate(params); - 是 pydantic model → 调
.model_validate(params); - 两者的失败(
SchemaError/ValidationError)统一转成ValueError抛出(activity_mixin.py:122-123)。
这个方法本身由动作解析环节(ActionsSubtask,属 02)在执行前调用;本章只需知道"校验这一步是 ActivityMixin 提供的、且兼容两种 schema 体系"。
4. 几个收尾机制(命名、记忆、依赖)
4.1 原生工具名:Tool_Activity
有些 provider(如支持原生 tool-calling 的模型)要求工具名是单一标识符。to_native_tool_name 把"工具名 + 动作名"拼成一个(tools/base_tool.py:228-248):
- 工具名只许字母数字(
^[a-zA-Z0-9]+$),否则报错; - 动作名许字母数字下划线(
^[a-zA-Z0-9_]+$); - 结果是
f"{tool_name}_{activity_name}",例如CalculatorTool_calculate。
provider 中立层怎么用这个名字对接不同模型的工具协议,见 03。
4.2 off_prompt / output_memory:把结果塞进 Task Memory
要解决的小问题: 有些工具输出很大(整个网页、整张表)。全塞回 prompt 会撑爆上下文。Griptape 让这类结果 不进 prompt,改存到 Task Memory,prompt 里只留一个引用。
两个开关:
off_prompt字段——工具级总开关,决定活动输出是否走 output memory(tools/base_tool.py:66,字段说明见:49)。output_memory字段——精确到"哪个动作的输出存进哪些 memory"的映射(tools/base_tool.py:60-62),初始化时会校验:引用的动作必须存在、memory 名不能重复(validate_output_memory,tools/base_tool.py:76-88)。
真正转存发生在 after_run(tools/base_tool.py:177-192):若配了 output_memory,按动作名取出对应的 memory 列表,逐个调 memory.process_output(...) 把结果落进去。
边界: Task Memory 内部怎么存、怎么被后续动作检索,属 05。本章只到"结果在这里被交给了 memory"。
4.3 依赖自动安装
Tool 可以带一个 requirements.txt(和工具类同目录)。初始化时若检测到有需求文件且未满足,自动 pip install(tools/base_tool.py:68-74 的 __attrs_post_init__;安装逻辑 install_dependencies :204-221;满足性检查 are_requirements_met :250-258)。这让第三方工具"开箱即用",不用用户手动装依赖。
5. 一个自定义 Tool 的示意
把本章串起来:写一个"字数统计"工具,含一个动作。
# 示意,非源码 —— 演示一个最小自定义 Tool
from schema import Literal, Schema
from griptape.tools import BaseTool
from griptape.artifacts import BaseArtifact, TextArtifact, ErrorArtifact
from griptape.utils.decorators import activity
class WordCountTool(BaseTool):
@activity(config={
"description": "统计一段文本里的单词数", # 给 LLM 看的说明
"schema": Schema({ # 给 LLM 看的参数结构
Literal("text", description="要统计的文本"): str,
}),
})
def count(self, params: dict) -> BaseArtifact: # 方法返回 Artifact
try:
text = params["values"]["text"] # 注意 {"values": ...} 这层
return TextArtifact(str(len(text.split())))
except Exception as e:
return ErrorArtifact(f"统计失败: {e}")
```
重点看三件事:(1)只声明 description + schema,框架自动生成 LLM schema;(2)取参走 params["values"],对应 3.5 的包装约定;(3)返回 Artifact,省得被 3.5 的兜底逻辑再包一层。装到 Agent 上、由 LLM 触发调用的过程,见 01 与 02。
6. 巧妙之处(可带走的技术)
- 用属性标记做"暗号",而非维护注册表。 是不是 activity,全靠方法对象上的
is_activity属性 + 反射扫描(activity_mixin.py:62),没有中心注册表,加动作 = 加个带装饰器的方法,零登记。 - schema 的两层"拆包"。 对外(给 LLM)的 schema 不含
{"values": ...}包装,对内(执行)才补上(base_tool.py:160-163)——LLM 只 看到干净的input,复杂性藏在框架里。 - 对低端模型的两处防御。 无参动作也留
Optional("input")(base_tool.py:123-126);缺参补None而非报错(decorators.py:97-99)。都是为了容忍模型不规矩的输出。 - 结果永远兜成 Artifact。 方法返回啥都行,
try_run保证下游拿到的一定是 Artifact(base_tool.py:165-173),类型边界干净。
7. 边界与局限
- 执行不做超时/沙箱。
try_run只 try/except 兜异常(base_tool.py:141-143),方法自己爱干嘛干嘛(计算器直接numexpr.evaluate);安全靠工具作者自律。 - 依赖自动
pip install有副作用。install_dependencies会真的改环境(base_tool.py:204-221),install_dependencies_on_init默认开;不想被动装包要显式关掉。 activities()每次都反射全量扫描,不能加@property缓存(会触发递归,见 3.2 注释),动作多时是重复开销。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| activity 装饰器 / 打标 | griptape/utils/decorators.py | activity, CONFIG_SCHEMA |
| 参数解包(松散字典→kwargs) | griptape/utils/decorators.py | _build_kwargs |
| 收集动作 + 名单过滤 | griptape/mixins/activity_mixin.py | activities, enable_activities, disable_activities |
| 渲染描述 / schema | griptape/mixins/activity_mixin.py | activity_description, activity_schema, activity_name |
| 入参校验(schema/pydantic) | griptape/mixins/activity_mixin.py | validate_activity_schema, to_activity_json_schema |
| 合成 Tool 级 JSON schema | griptape/tools/base_tool.py | schema, activity_schemas |
| 三段式执行 + 容错兜底 | griptape/tools/base_tool.py | run, before_run, try_run, after_run |
| 原生工具命名 | griptape/tools/base_tool.py | to_native_tool_name |
| 结果转存 Task Memory | griptape/tools/base_tool.py | after_run, output_memory, validate_output_memory |
| 依赖自动安装 | griptape/tools/base_tool.py | install_dependencies, are_requirements_met, __attrs_post_init__ |
| 参考实现(最简工具) | griptape/tools/calculator/tool.py | CalculatorTool.calculate |
同组其它章:index · 01 结构与任务图 · 02 PromptTask 智能体循环 · 03 驱动与 provider 中立 · 05 记忆与制品 · 06 引擎与配置