数据截至 (上游 commit 2e970421f2b0)
记忆与制品:对话记忆、off-prompt 任务记忆、Artifact 类型系统
30 秒导读: Griptape 有两块数据基座。一块是记忆——把"该记住的东西"存在框架里,分三种:对话历史(Conversation Memory)、大块工具输出(Task Memory)、跨任务共享的元数据(Meta Memory)。另一块是 Artifact 类型系统——所有部件之间传的每一份数据都装在一个带类型的"信封"里(文本、JSON、二进制、图片、动作、错误……)。两者在一个关键机制上交汇:off-prompt——工具吐出的大/敏感结果不直接塞进提示词,而是留在 Task Memory 里,只把一句"它存哪了"的引用喂回模型。
本章讲这两块数据侧的东西。工具为什么/如何触发 off-prompt(off_prompt 开关怎么接线)在工具与 activity 机制讲;Task Memory 底下的向量检索(RAG)在引擎与配置讲。这里只讲:记忆怎么存、Artifact 有哪些类型、off-prompt 的数据怎么流。
1. 这是什么(零基础也能懂)
先建立两个心智模型。
记忆 = 智能体的"记事本"。 一个 LLM 本身是无状态的:每次调用都是一张白纸,不记得上一轮说过什么。要让智能体"记得住",就得有人在框架侧把该记的东西存下来、下一轮再拼回提示词。Griptape 把"该记的东西"分成三类,对应三种记忆:
| 记忆种类 | 记什么 | 一句话类比 |
|---|---|---|
| Conversation Memory(对话记忆) | 每一轮的"你问 / 它答" | 聊天记录本 |
| Task Memory(任务记忆) | 工具吐出的大块/敏感结果 | 后台文件柜(只给别人递取件号) |
| Meta Memory(元记忆) | 跨任务共享的零碎元数据 | 便利贴 |
Artifact = 数据的"信封"。 Griptape 里,部件之间(任务→任务、工具→记忆、驱动→任务)传的从来不是裸的 str 或 bytes,而是一个 Artifact 对象——它裹着 value(真数据)、name(名字)、meta(附加信息),并保证有一个 to_text() 能变成给 LLM 看的文本。不同种类的数据用不同的 Artifact 子类:文本用 TextArtifact、二进制用 BlobArtifact、出错用 ErrorArtifact……
为什么 要有信封? 因为整条流水线要能统一处理任意数据:记忆要能判断"这是文本还是二进制,该存哪";任务要能把上游产物 to_text() 拼进提示词;工具要能把结果打包回传。有了统一的 BaseArtifact 契约,这些部件就不用关心里面到底是什么——它们只跟"信封"打交道。
2. 顶层全景(两块基座怎么转)
┌─────────────────────────────────────────┐
│ Artifact 类型系统 │
│ 所有部件之间流动的数据都装在信封里 │
│ Text / Json / List / Blob / Image / │
│ Action / Error / Info / Boolean ... │
└─────────────────────────────────────────┘
▲ ▲ ▲
装进信封 │ │ 装进信封 │ 装进信封
│ │ │
┌──────────────────────┴──┐ ┌──────┴───────┐ ┌───┴────────────────┐
│ Conversation Memory │ │ Task Memory │ │ Meta Memory │
│ 跨轮:每轮 input/output │ │ off-prompt: │ │ 跨任务:元数据条目 │
│ → 拼回提示词的对话历史 │ │ 大块工具输出 │ │ (thought/action…) │
└──────────────────────────┘ └──────────────┘ └─────────────────────┘
memory/structure memory/task memory/meta
三种记忆各自用 Artifact 当存取 单位,但职责完全不同:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ConversationMemory | 累积每轮 Run,to_prompt_stack 把历史还原成 user/assistant 消息 | memory/structure/conversation_memory.py |
SummaryConversationMemory | 同上,但超过 offset 的老 Run 自动用 LLM 压成一段摘要 | memory/structure/summary_conversation_memory.py |
TaskMemory | 按 Artifact 类型把工具输出分派到不同 storage,返回"取件号" | memory/task/task_memory.py |
TextArtifactStorage / BlobArtifactStorage | 具体落地:文本进向量库、二进制进内存字典 | memory/task/storage/ |
MetaMemory | 存 BaseMetaEntry 列表,供下游任务读 | memory/meta/meta_memory.py |
BaseArtifact 及其子类 | 数据信封,定义 value/name/meta/to_text 契约 | artifacts/*.py |
3. Conversation Memory:跨轮的对话历史
3.1 一个 Run 就是一轮问答
对话记 忆的最小单位是 Run——一轮交互的"输入 + 输出",两边都是 Artifact(run.py:14-19):
@define(kw_only=True)
class Run(SerializableMixin):
id: str = field(...) # 随机 uuid
meta: dict | None = field(...)
input: BaseArtifact = field(...) # 这轮喂进去的
output: BaseArtifact = field(...) # 这轮吐出来的
ConversationMemory 就是一串 Run 的容器。它的两个核心动作极其朴素(conversation_memory.py:11-20):
def try_add_run(self, run: Run) -> None:
self.runs.append(run) # 追加一轮
def to_prompt_stack(self, last_n=None) -> PromptStack:
for run in runs:
prompt_stack.add_user_message(run.input) # 还原成 user 消息
prompt_stack.add_assistant_message(run.output) # 还原成 assistant 消息
也就是说:存的时候按轮追加,取的时候把每轮拆成一条 user + 一条 assistant,拼成一段对话史。
3.2 落记忆的时机:per_structure vs per_task
记忆什么时候被写进去?Griptape 给了两个策略,由 conversation_memory_strategy 控制。
per_structure(默认):整个结构跑完才落一条 Run。 在 Structure.after_run 里,拿首个任务的输入和末个任务的输出打包成一个 Run(structures/structure.py:176-185):
if (self.conversation_memory_strategy == "per_structure"
and self.conversation_memory is not None ...):
run = Run(input=self.input_task.input, output=self.output_task.output)
self.conversation_memory.add_run(run)
per_task:每个任务各落一条 Run。 在 PromptTask.after_run 里,只要不是 per_structure 就每个任务自己落(tasks/prompt_task.py:198-206):
if ((self.structure is None or self.structure.conversation_memory_strategy == "per_task")
and conversation_memory is not None and self.output is not None):
run = Run(input=self.input, output=self.output)
conversation_memory.add_run(run)
一句话记住这个区别:
| 策略 | 落记忆的粒度 | 适合 |
|---|---|---|
per_structure | 一次 run() 存一条(首输入→末输出) | 把多任务流水线当成"一轮"对外 |
per_task | 每个任务各存一条 | 想让每个任务步骤都进历史 |
3.3 历史注入到提示词的哪儿:system 之后
历史存下来后,由 PromptTask 在组装提示词时插回去。关键是插的位置——紧跟在 system 提示词后面(tasks/prompt_task.py:142-144):
if memory is not None:
# inserting at index 1 to place memory right after system prompt
memory.add_to_prompt_stack(self.prompt_driver, stack, 1 if system_template else 0)
提示词怎么组装的全貌在 PromptTask 智能体循环 讲;本节只讲存储侧——记忆怎么把自己变成一段可插入的消息。
3.4 存取的旋钮:autoload / max_runs / autoprune
BaseConversationMemory 上挂了几个控制存取行为的字段(base_conversation_memory.py:21-28):
| 字段 | 默认 | 作用 |
|---|---|---|
autoload | True | 初始化时自动从 driver 把历史 Run 读回来(:30-32, :58-63) |
max_runs | None | 上限;add_run 后超出就从头 pop(:44-48) |
autoprune | True | 注入提示词时,按 token 上限能塞几条塞几条 |
autoprune 是最值得看的一处巧思(base_conversation_memory.py:65-114)。它不是简单截断,而是试探性地二分:从"想塞全部 Run"开始,反复用 tokenizer 数一遍拼进去后还剩多少 token,剩负数就砍掉一条再试,直到刚好塞得下:
while should_prune and num_runs_to_fit_in_prompt > 0:
memory_inputs = self.to_prompt_stack(num_runs_to_fit_in_prompt).messages
tokens_left = prompt_driver.tokenizer.count_input_tokens_left(...)
if tokens_left > 0:
should_prune = False # 塞得下,停
else:
num_runs_to_fit_in_prompt -= 1 # 塞不下,少一条再试
每次 add_run 之后还会调 conversation_memory_driver.store(...) 落盘(:48)——具体存到哪(内存 / 本地文件 / Redis 等)由 driver 决定,属于驱动与 provider 中立的范畴。
3.5 变体:SummaryConversationMemory 自动摘要
对话一长,历史 token 会爆。SummaryConversationMemory 的办法是:只保留最近 offset 条原文,更老的用 LLM 压成一段摘要(summary_conversation_memory.py)。
每次加新 Run 时判断:未摘要的 Run 超过 offset 了吗?超了就把多出来的老 Run 交给 LLM 压进摘要,并把 summary_index 往前推(:87-94):
def try_add_run(self, run: Run) -> None:
self.runs.append(run)
unsummarized_runs = self.unsummarized_runs()
runs_to_summarize = unsummarized_runs[: max(0, len(unsummarized_runs) - self.offset)]
if len(runs_to_summarize) > 0:
self.summary = self.summarize_runs(self.summary, runs_to_summarize)
self.summary_index = 1 + self.runs.index(runs_to_summarize[-1])
注入提示词时,就只发摘要 + 最近 offset 条原文(:67-74)。注意一个细节:self.runs 里始终保留全部原始 Run(供检视),摘要只影响"发给 LLM 的那份"。