跳到主要内容

PromptTask 智能体循环:提示词组装与 ReAct 子任务

30 秒导读: 在 Griptape 里,让 LLM「反复调用工具直到给出答案」的那台发动机,就是一个 PromptTask。本章讲清它两件核心工作:(1) 每次调 LLM 前,如何把系统提示、用户输入、历史工具回合、对话记忆拼成一份完整上下文(prompt_stack);(2) 拿到 LLM 的回话后,如何用 ActionsSubtask 把它解析成「思考 + 一组动作」,并行执行工具,再把结果回灌 LLM——循环往复,直到 LLM 说出 Answer

本章聚焦「单个 Task 内部的智能体循环」。谁来调度多个 Task(Agent / Pipeline / Workflow)见 01-structures-and-task-graph.md;LLM 请求怎么真正发出去见 03-drivers-and-provider-neutrality.md;工具本身的 schema 与分发见 04-tools-and-activities.md


1. 这是什么(零基础也能懂)

  • 一句话定义: PromptTask 是「一次让 LLM 干活」的最小单元。给它一段输入和一堆工具,它会自己盘旋——问 LLM、执行工具、把结果告诉 LLM、再问——直到 LLM 给出最终答案。

  • 解决什么问题: 光调一次 LLM 常常不够。比如「北京现在几点、比纽约早几小时」,LLM 得先调一个时钟工具、拿到结果、再算差值才能回答。这种「想一步、做一步、看结果、再想」的来回,就是 ReAct(Reason + Act,推理与行动交替)。PromptTask 把这套来回封装好,你只管给输入和工具。

  • 它能做什么:

    • 把 system 模板 + 用户输入 + 历史动作 + 对话记忆拼成一次调用的完整上下文。
    • 解析 LLM 的回话,认出里面要调哪些工具、传什么参数。
    • 并行执行多个工具动作,校验参数是否符合工具的 schema。
    • 把工具输出回灌给 LLM,循环到出答案;并用 max_subtasks 兜底防死循环。
  • 用起来什么样: 下面是最小用法,一个装了工具的 Agent 内部就是一个 PromptTask

# 示意,非源码 —— 展示 PromptTask 在做什么
from griptape.tasks import PromptTask
from griptape.tools import DateTimeTool

task = PromptTask(
input="现在几点?",
tools=[DateTimeTool()], # 给它「手脚」
)
result = task.run() # 内部:问 LLM → 调 DateTimeTool → 把时间回灌 LLM → 出答案
print(result.value)
  • 一句话直觉:PromptTask 想成一个「带便签本的助手」。便签本(prompt_stack)每一轮都重抄一遍:最上面是岗位职责(system),然后是你的问题,然后是「我上一步调了什么工具、得到什么」的流水账。每次它都拿着这整本便签去问 LLM,所以 LLM 永远看得到全部来龙去脉。

本节不碰底层。目标:你现在知道「PromptTask = 一个会自己反复调工具的循环」。


2. 顶层全景(一次 run 大概怎么转)

PromptTask.run() 继承自 BaseTask.run(),它只是个骨架:before_run → try_run → after_run,任何异常都兜成 ErrorArtifact,最后状态置 FINISHED(base_task.py:170-188)。真正的智能体循环在 try_run 里。

部件一句话职责:

部件干什么在哪里
prompt_stack(属性)每轮把 system+输入+历史子任务+记忆拼成一次调用的完整上下文prompt_task.py:125-146
try_run循环入口:先跑一次 LLM,再依次过 subtask_runners 管线prompt_task.py:208-221
default_run_actions_subtasks工具循环本体:解析动作 → 执行 → 回灌 LLM → 再来prompt_task.py:300-325
ActionsSubtaskReAct 引擎:解析 LLM 文本/结构、绑定工具、并行执行actions_subtask.py 整个类
prompt_driver真正把 prompt_stack 发给某个 LLM provider03 章

主线走一遍(高层,不进代码):

输入 ──► try_run

├─(1) prompt_driver.run(prompt_stack) # 先问一次 LLM,得初步回话

└─(2) 依次过 subtask_runners 管线:
├─ default_run_actions_subtasks # 工具循环(本章重点)
└─ default_run_output_schema_validation_subtasks # 若设了 output_schema,逼 LLM 产出合规结构

└──► 最终 Answer / 结构化输出 / ErrorArtifact

注意 (1) 和 (2) 里每次「问 LLM」用的都是同一个 prompt_stack 属性——而它是动态计算的:每次访问都重新遍历当前 self.subtasks,把已经跑过的工具回合重新拼进去。这就是「回灌」得以发生的机制,下一节详解。


3. 核心机制一:prompt_stack —— 每轮重新组装的完整上下文

  • 它要解决的小问题: LLM 是无状态的——你这次调用没告诉它的,它就不知道。所以每一轮都必须把「岗位职责 + 原始问题 + 到目前为止做过什么」完整地重新递给它。

  • 思路: prompt_stack 写成一个 property 而非缓存字段。每次读它,都按固定顺序现拼一份新的 PromptStack(一串带角色的消息)。顺序是精心设计的。

装配顺序(prompt_task.py:126-146):

PromptStack(tools=..., output_schema=...) # 带上工具清单与期望输出 schema

├─ ① add_system_message(system_template) # 岗位职责:规则 + 动作格式 + 工具名单
├─ ② [conversation_memory 插在这里] # 见下方「记忆插位」
├─ ③ add_user_message(self.input) # 原始用户输入(经 Jinja 渲染)
└─ ④ 若还没有最终 output:
for s in self.subtasks: # 把每个历史子任务的「动作+结果」回放进来
s.add_to_prompt_stack(stack)

记忆插位是个巧点(prompt_task.py:142-144):对话记忆不是简单追加到末尾,而是「有 system 就插在 index 1(紧跟 system 之后),否则插在 index 0」。这样长期记忆永远紧贴岗位职责、排在当轮用户输入之前,符合 LLM 对「背景知识在前、当前任务在后」的偏好。

真实代码,注意 ③④ 的分叉:

# prompt_task.py:134-141 —— 有最终输出就回放输出,否则回放所有子任务
stack.add_user_message(self.input)

if self.output:
stack.add_assistant_message(self.output.to_text())
else:
for s in self.subtasks:
s.add_to_prompt_stack(stack)
  • 关键细节:
    • system_templatedefault_generate_system_template(prompt_task.py:231-243)用 system.j2 渲染,里面塞进了 rulesets、动作 JSON schema、工具名单,以及两个开关文本:use_native_toolsreflect_on_tool_use(决定要不要教 LLM 用 Thought:/Actions:/<|Response|> 的 ReAct 文本格式)。
    • 因为 每轮都重新遍历 self.subtasks,所以「上一轮工具的输出」是通过子任务列表增长 + prompt_stack 重算回灌给 LLM 的——不是手工拼字符串。这是整个循环的关节。

4. 核心机制二:try_run 与 subtask_runners 管线

  • 它要解决的小问题: 一次 LLM 回话可能是「最终答案」,也可能是「我要调工具」,还可能需要「产出的结构不合规、得重来」。得有一条流水线把这些情况依次处理掉。

  • 思路: try_run 先无条件问一次 LLM 拿初步输出,然后把这个输出依次喂给 subtask_runners 列表里的每个处理器,每个处理器要么原样透传、要么把它加工成新输出。

真实代码,循环的主干:

# prompt_task.py:213-221
output = self.prompt_driver.run(self.prompt_stack).to_artifact(
meta={"is_react_prompt": not self.prompt_driver.use_native_tools}
)
for subtask_runner in self.subtask_runners:
output = subtask_runner(output)

默认两个 runner(prompt_task.py:81-87):

runner职责触发条件
default_run_actions_subtasks跑工具循环(ReAct 本体)tools 才干活,否则原样透传
default_run_output_schema_validation_subtasks逼 LLM 产出符合 output_schema 的结构设了 output_schema 才干活

那个 meta={"is_react_prompt": ...} 标记很关键:它告诉下游的 ActionsSubtask——这段输出到底是「ReAct 文本」(要正则解析)还是「native 结构化动作」(直接读对象)。开关就是 prompt_driver.use_native_tools

4.1 工具循环:default_run_actions_subtasks

这是本章的心脏。逐行看它怎么盘旋(prompt_task.py:300-325):

# prompt_task.py:300-325(节选)
if not self.tools:
return subtask_input # 没工具,直接透传
subtask = self.add_subtask(ActionsSubtask(subtask_input, ...)) # 解析首轮回话

while subtask.output is None: # output 还没定 = 这轮是动作,不是答案
if len(self.subtasks) >= self.max_subtasks:
subtask.output = ErrorArtifact(f"Exceeded tool limit ...") # 兜底防死循环
else:
subtask.run() # 并行执行本轮的工具动作
if self.reflect_on_tool_use:
output = self.prompt_driver.run(self.prompt_stack).to_artifact(...) # 把结果回灌 LLM
subtask = self.add_subtask(ActionsSubtask(output)) # 解析下一轮回话
return subtask.output

理解这个循环的钥匙:subtask.output is None 就代表「LLM 还想调工具」。

  • ActionsSubtask 一被 add_subtask,就在 attach_to 里立刻解析输入。如果解析出的是 Answer(没有动作),它当场把 output 置为答案 → while 条件为假 → 循环退出,返回答案。
  • 如果解析出的是一组动作,output 保持 None → 进循环体:subtask.run() 执行工具(把 output 填成工具结果),然后reflect_on_tool_use,再问一次 LLM(此时 prompt_stack 已把刚跑完的动作+结果回放进去),拿到的新回话又包成新 ActionsSubtask 赋回 subtask,进入下一圈判断。

两个开关改变循环形状:

开关True(默认)False
reflect_on_tool_use执行工具后再回灌 LLM,让它看结果决定下一步——真正的多轮 ReAct执行完工具就返回原始工具输出,不给 LLM 复盘
max_subtasks(默认 20)子任务数达上限即塞 ErrorArtifact 退出——(数值上限,不是布尔)

default_run_output_schema_validation_subtasks(prompt_task.py:327-344)结构几乎一样,只是把「执行工具」换成「用 OutputSchemaValidationSubtask 校验结构,不合格就再问 LLM 重产」,同样受 max_subtasks 兜底。

4.2 一张回合制循环图(LLM ↔ 工具)

怎么读: 上半是 LLM 的地盘,下半是工具的地盘。中间那条竖线是 prompt_stack —— 每轮工具跑完,结果都被写回它,LLM 下一轮就看得见。命中 Answer 即跳出。

┌──────────────────────── prompt_stack(每轮重算)────────────────────────┐
│ system 职责 + 用户输入 + [历史: 动作①→结果① / 动作②→结果② …] + 记忆 │
└───────────────────────────────────┬───────────────────────────────────┘
│ 递给 LLM

┌──────────────────┐
┌───────────►│ 问 LLM 一轮 │
│ │ prompt_driver.run │
│ └────────┬─────────┘
│ │ 回话包成 ActionsSubtask 解析
│ ▼
│ ┌──────────────────┐ 有 Answer,无动作
│ │ 是动作 还是 答案? │────────────────────► 返回最终答案 ✔
│ └────────┬─────────┘
│ │ 是一组动作(output is None)
│ ▼
│ ┌──────────────────┐
│ │ 并行执行工具动作 │ run_actions(futures)
│ │ (04 章讲 dispatch)│
│ └────────┬─────────┘
│ │ 结果写回子任务列表 → prompt_stack
reflect_on_tool_use=True │
└─────────────────────┘
(reflect=False:不回灌,直接返回工具输出)

兜底:len(subtasks) ≥ max_subtasks(默认 20)→ 塞 ErrorArtifact 强制跳出

5. 核心机制三:ActionsSubtask —— ReAct 引擎

ActionsSubtask 是把「LLM 的一段回话」翻译成「可执行动作」的地方,也是本框架真正的 ReAct 实现。

5.1 三个正则:认出 Thought / Actions / Answer

当输入是 ReAct 文本(非 native),attach_to__init_from_prompt,用三个类级正则切分 LLM 的回话(actions_subtask.py:38-40、266-283):

常量匹配什么定义处
THOUGHT_PATTERNThought: ... 推理段actions_subtask.py:38
ACTIONS_PATTERNActions: [ ...JSON数组... ]actions_subtask.py:39
ANSWER_PATTERNAnswer: ... 最终答案actions_subtask.py:40

关键分流逻辑(actions_subtask.py:276-282):既没解析出动作、output 又还是 None 时——有 Answer 就把它当最终输出;连 ReAct 格式都没遵守,就把 LLM 的整段原文兜底当输出。这保证循环一定能终止,不会因格式跑偏而卡死。

# actions_subtask.py:276-282 —— 没有动作时的终止兜底
if not self.actions and self.output is None:
if answer_matches:
self.output = TextArtifact(answer_matches[-1]) # 正常:最终答案
else:
self.output = TextArtifact(value) # 兜底:LLM 没守格式,原文当输出

5.2 把 JSON 动作绑定到工具并校验

__parse_actions(actions_subtask.py:316-329)对 Actions: 里的 JSON 数组做 json.loads(strict=False),逐个交给 __process_action_object(actions_subtask.py:331-361)。后者做四件事:

  1. tag / name / path 三个必填键(缺任意一个直接抛异常)。
  2. input 就先 remove_null_values_in_dict_recursively 清掉 null——注释里说这是绕过 schema 库把 Or(str, None) 翻成 JSON schema 的 bug,LLM 常塞 null 值把校验器搞崩(actions_subtask.py:342-349)。
  3. name 到宿主任务 find_tool 找到真正的工具对象(actions_subtask.py:352-353)。
  4. 组装成 ToolAction,再过 __validate_action(actions_subtask.py:363-384):按 path 取到 activity,拿它的 activity_schema 校验 input;不合规就把该动作的 output 直接设成 ErrorArtifact——注意是标记该动作失败,而非抛异常中断整批。

工具与 activity、schema 的细节属于 04 章,这里只需知道:动作在真正执行前,参数已按工具声明的 schema 校验过。

5.3 并行执行多个动作

一轮里 LLM 可以一次给多个动作,run_actions 用线程池 futures 并行跑(actions_subtask.py:140-156):

# actions_subtask.py:140-144
def run_actions(self, actions):
with self.create_futures_executor() as futures_executor:
return utils.execute_futures_list(
[futures_executor.submit(with_contextvars(self.run_action), a) for a in actions]
)

每个 run_action(actions_subtask.py:146-156)取动作绑定的 toolpath,调 action.tool.run(...),把结果记到 action.output,返回 (tag, output)try_run(actions_subtask.py:116-138)再把所有结果按 tag 命名、打包成一个 ListArtifact 作为本子任务的 output

5.4 native vs ReAct —— 回灌时的两条路

工具结果怎么回写进 prompt_stack,add_to_prompt_stack(actions_subtask.py:200-236)按 use_native_tools 分叉:

路径条件怎么回写代码
native(结构化)use_native_tools=Trueassistant 消息放结构化 ActionArtifact(动作调用),user 消息放 ActionArtifact(动作结果);没结果时补一句「Please keep going」actions_subtask.py:203-233
ReAct(文本)否则渲染 assistant_actions_subtask.j2 / user_actions_subtask.j2 两个模板成纯文本回写actions_subtask.py:234-236
  • native 路径靠 LLM provider 原生的 function-calling 协议,动作以结构化对象往返,不依赖文本格式,更稳。
  • ReAct 路径把动作与结果渲染成 Thought:/Actions:/<|Response|>: 文本(模板见 templates/tasks/prompt_task/),用于不支持原生工具调用的模型。两条路对上层循环透明——default_run_actions_subtasks 完全不用关心走哪条。

对应地,输入解析也分叉:native 输入是 ListArtifact(含 ActionArtifact),走 __init_from_artifact(actions_subtask.py:284-314);ReAct 输入是带 is_react_prompt 元标记的 TextArtifact,走 __init_from_prompt。分流点在 attach_to(actions_subtask.py:76-80)。


6. BaseSubtask —— 子任务如何嵌进任务图(简要)

ActionsSubtask 继承 BaseSubtask(base_subtask.py:22-57),后者是「把子任务挂进宿主 Task」的薄封装:

  • origin_task(base_subtask.py:24-28):子任务反向指到创建它的那个 PromptTask;find_tool / find_subtask 都通过它转发。
  • parents / children(base_subtask.py:30-40):子任务之间也连成链——add_subtask(prompt_task.py:276-286)每加一个新子任务,就把它挂成上一个的 child,于是同一个 Task 内部的多轮 ReAct 形成一条线性子任务链
  • add_to_prompt_stack(base_subtask.py:55-57)是抽象方法,由 ActionsSubtask 实现(即 5.4 那两条路)。

一句话:BaseSubtask 让「一个 Task 内部的多轮工具调用」复用了框架已有的「任务节点 + 父子链 + run 生命周期」基建,而不必另造一套。


7. 巧妙之处(可借鉴)

  • 用「property 动态重算 prompt_stack」实现回灌,而非手工拼历史字符串。 每轮只需把新子任务 append 进列表,下次读 prompt_stack 时历史自动带上(prompt_task.py:139-140)。状态与视图解耦,循环体极干净。

  • subtask.output is None 一个信号量兼任「继续/停止」判据。 解析出动作 → None → 继续;解析出答案 → 非 None → 停止(prompt_task.py:313)。不需要额外的 is_done 标志。

  • 格式跑偏也能终止。 LLM 没守 ReAct 格式时,整段原文兜底当答案(actions_subtask.py:281-282),配合 max_subtasks 双保险,循环永不失控。

  • 动作校验失败是「标记」不是「抛」。 单个动作 schema 不过,只把该动作 output 设成 ErrorArtifact(actions_subtask.py:384),错误随结果回灌给 LLM,让它自己纠错重试,而不是崩掉整个任务。

  • reflect_on_tool_use 一个开关切换「Agent 模式」与「一次性工具管道」。 关掉即「调一次工具就返回、不复盘」,适合确定性流水线(prompt_task.py:319-323)。


8. 边界与局限

  • 本章不覆盖: 工具的 @activity 装饰器如何变出可调 schema、tool.run 内部 dispatch → 见 04 章;prompt_driver.run 如何把 PromptStack 翻成某 provider 的 HTTP 请求、use_native_tools 底层协议 → 见 03 章;对话记忆 / 任务记忆 / Artifact 类型 → 见 05 章

  • 循环上限硬编码语义。 max_subtasks 到顶时返回的是 ErrorArtifact(prompt_task.py:314-315),不是「尽力而为的部分答案」——调用方需自行处理这种截断。

  • 并行动作共享一个宿主 Task 状态。 run_actions 用 futures 并行(actions_subtask.py:140-144),动作间若有隐含顺序依赖,框架不保证——由 LLM 自己决定是否拆成多轮串行。

  • # TODO: Remove these fields ... in Griptape 2.0(prompt_task.py:306):首个 ActionsSubtask 还在显式透传几个模板字段,属过渡代码,后续版本行号/签名可能漂移——定位以符号名为准。


9. 代码地图(导航索引)

主题文件路径符号名
一次调用的上下文组装griptape/tasks/prompt_task.py:125PromptTask.prompt_stack
system 模板渲染(含开关)griptape/tasks/prompt_task.py:231default_generate_system_template
循环入口 + runner 管线griptape/tasks/prompt_task.py:208PromptTask.try_run
工具循环本体griptape/tasks/prompt_task.py:300default_run_actions_subtasks
结构校验循环griptape/tasks/prompt_task.py:327default_run_output_schema_validation_subtasks
挂载子任务成链griptape/tasks/prompt_task.py:276PromptTask.add_subtask
run 生命周期骨架griptape/tasks/base_task.py:170BaseTask.run
ReAct 三正则griptape/tasks/actions_subtask.py:38-40THOUGHT_PATTERN / ACTIONS_PATTERN / ANSWER_PATTERN
文本回话解析 + 终止兜底griptape/tasks/actions_subtask.py:266__init_from_prompt
结构化回话解析griptape/tasks/actions_subtask.py:284__init_from_artifact
JSON 动作 → 工具绑定griptape/tasks/actions_subtask.py:316:331__parse_actions / __process_action_object
动作参数 schema 校验griptape/tasks/actions_subtask.py:363__validate_action
并行执行动作griptape/tasks/actions_subtask.py:140run_actions / run_action
native vs ReAct 回写griptape/tasks/actions_subtask.py:200ActionsSubtask.add_to_prompt_stack
子任务挂载基建griptape/tasks/base_subtask.py:22BaseSubtask