跳到主要内容

工具与 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=Truenameconfig,并包一层参数解包utils/decorators.py:31-50
ActivityMixin.activities()inspect.getmembers 扫出所有带标记的方法,按 allow/deny 名单过滤mixins/activity_mixin.py:55-65
ActivityMixin 渲染族activity_description / activity_schema 把 config 渲染成描述和参数 schemamixins/activity_mixin.py:79-102
ActivityMixin.validate_activity_schema用 schema 或 pydantic 校验 LLM 传来的入参mixins/activity_mixin.py:116-123
BaseTool.schema()把该 Tool 所有 activity 的 schema 合成一个"或"结构,输出 JSON schematools/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):

  1. 先校验 config。 传进来的 config 字典必须过 CONFIG_SCHEMA——description 必填(str),schema 可选、且必须是 Schema / 可调用 / pydantic BaseModel 子类之一(utils/decorators.py:17-25)。没写 schema 就默认置 None(utils/decorators.py:36-37)。
  2. 包一层 wrapper。 真实调用时框架传进来的是一个 params 字典,wrapper 负责把它拆成关键字参数再喂给原方法(utils/decorators.py:41-42,细节见 3.4)。
  3. 挂三个标记: name(默认取函数名)、configis_activity=True(utils/decorators.py:44-46)。

关键细节: is_activity 这个布尔标记是整章的"暗号"——后面所有"这方法是不是一个动作"的判断,都是 getattr(method, "is_activity", False)。没这个标记的方法框架 看都不看

3.2 activities():扫出动作,并按名单过滤

要解决的小问题: 一个 Tool 类里既有 activity 方法,也有普通辅助方法(runvalidate…)。怎么只挑出前者?还想允许用户 临时禁用 某几个动作(比如给 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

两个值得记的点:

  1. name / path / input 三段式。 LLM 产出的一次调用意图就是这三个字段:哪个工具(name)、哪个动作(path)、什么参数(input)。
  2. 无参动作也保留 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
NoneInfoArtifact("Tool returned an empty value")base_tool.py:170-171
其它任意值InfoArtifact(值) + 打一条 warningbase_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)。
  • 若签名显式要 paramsvalues,把原始字典/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 触发调用的过程,见 0102


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.pyactivity, CONFIG_SCHEMA
参数解包(松散字典→kwargs)griptape/utils/decorators.py_build_kwargs
收集动作 + 名单过滤griptape/mixins/activity_mixin.pyactivities, enable_activities, disable_activities
渲染描述 / schemagriptape/mixins/activity_mixin.pyactivity_description, activity_schema, activity_name
入参校验(schema/pydantic)griptape/mixins/activity_mixin.pyvalidate_activity_schema, to_activity_json_schema
合成 Tool 级 JSON schemagriptape/tools/base_tool.pyschema, activity_schemas
三段式执行 + 容错兜底griptape/tools/base_tool.pyrun, before_run, try_run, after_run
原生工具命名griptape/tools/base_tool.pyto_native_tool_name
结果转存 Task Memorygriptape/tools/base_tool.pyafter_run, output_memory, validate_output_memory
依赖自动安装griptape/tools/base_tool.pyinstall_dependencies, are_requirements_met, __attrs_post_init__
参考实现(最简工具)griptape/tools/calculator/tool.pyCalculatorTool.calculate

同组其它章:index · 01 结构与任务图 · 02 PromptTask 智能体循环 · 03 驱动与 provider 中立 · 05 记忆与制品 · 06 引擎与配置