BaseTool Prompt 注入能力增强(prompt_position + format_2_prompt)¶
| 字段 | 值 |
|---|---|
| 状态 | PRD v7(S1/S2/S3 已落地,S4 待落地) |
| 日期 | 2026-05-08 |
| v2 变更 | (1) 命名收敛 context_position → prompt_position;(2) 父类框架方法 + 子类业务点双层(参考 run/_run);(3) 子类返回 str \| BasePrompt,父类自动归一化;(4) LLM 注入器从「字符串桶聚合」退化为「BasePrompt 临时合并到 *_prompt 列表」 |
| v3 变更 | 撤回 v2 的 ToolContextPrompt(专用类)→ 改为新增 NormalizePrompt:BasePrompt 体系的最低限度具体化(与 AdditionalInfoPrompt 同形 —— 持有 template,但不裁剪 ctx,全字段 model_dump 后注入 template.render);BaseTool 框架兜底将工具 str 包成 NormalizePrompt,并在此唯一一处自动转义字面花括号 —— 工具 dev 无需了解 fstring 转义规则 |
| v4 变更 | 双层方法名 render_context / _render_context → format_2_prompt / _format_2_prompt,与 BasePrompt 体系 format_2_str / format_2_multimodal 命名同构;调用链路 tool.format_2_prompt(ctx) → BasePrompt → prompt.format_2_str(ctx) → str 自描述 |
| v5 变更 | prompt_position tuple 内允许 None:tuple[ChatPos \| None, DeskPos \| None] —— 支持单边注入语义(如"仅 ChatLLM 注入,DeskLLM 下沉默")。校验规则:覆写 _format_2_prompt 时至少一边非 None;(None, None) 拒绝(与 prompt_position = None 重复)。把 v4 "强制双适配"细化为「强制思考 + 显式选择」,避免被迫填凑数位 |
| v6 变更 | 合法注入位收紧 —— 排除「用户意图表达位」与「系统维护的历史快照位」。DeskLLM 合法位从 7 → 4:purpose_prompt / prefix_prompt 是用户意图核心(工具不可篡改用户原话,否则 bug 导致意图紊乱难追溯),original_desk_screenshot_prompt 是系统维护的初始快照(属于历史,工具不可改写历史,否则破坏 desk 时间线因果性)。在 Literal 定义处加 inline 注释,给后来开发者明确判据 |
| v7 变更 | S1 落地复盘后的设计收敛:(1) PromptContext 不持工具引用 —— 取消 v6 §3.4 的 _active_tool_instances PrivateAttr 设计,BaseLLM construct_prompt_context(tools=...) 入参本身就持有 BaseTool list,绕道 ctx 是没必要的中转;(2) BaseLLM 取消 _llm_family ClassVar,注入器方法名改为 _collect_dynamic_prompts(prompt_ctx, tools, position_index: int),子类(ChatLLM/各 DeskLLM adapter)调用端立位传 0 / 1,避免引入额外 ClassVar 间接层;(3) dynamic_prompts 是装配方法内的短生命周期局部 dict —— 不挂在 ctx 上、不挂在 LLM 实例上,方法内即用即弃;(4) 命名升格:tool_context_buckets → dynamic_prompts,为未来非工具 provider(如 Memory)留出语义空间 |
| 作者 | JQQ + Claude |
| 涉及模块 | tfrobot/drive/tool/base.py、tfrobot/brain/chain/llms/base.py、tfrobot/brain/chain/llms/chat_llm.py、tfrobot/brain/chain/llms/generation_llms/desk_llm/*.py、tfrobot/brain/chain/prompt/normalize_prompt.py、tfrobot/brain/chain/prompt/__init__.py、tfrobot/schema/exceptions.py |
| 关联 Epic / Issue | TFROB-463(Epic)/ TFROB-464(S1,已落地)/ TFROB-465(S2)/ TFROB-466(S3)/ TFROB-467(S4) |
一、背景与问题¶
1.1 现状¶
BaseTool 现有契约是纯函数式:
LLM 决策调用 → run(tool_params) → ToolReturn → 写回 intermediate_msgs
↑
下一轮 LLM 调用读到 ToolReturn 文本
工具实例虽然 connect_to_neural 后长期挂在 Drive → Robot 上,但框架层从未给工具一个「在 LLM 调用前主动注入上下文」的位置。工具想跨调用持有状态时,只剩两条非正规路径:
| 路径 | 问题 |
|---|---|
Path A. 利用 CurrentInput.additional_info / runtime_vars |
这两个字段本来语义是"本次 LLM 调用的临时槽",生命周期与单次 complete() / chain.run() 同寿。无法跨用户输入存活。另外语义被滥用:状态混在 trace / response_format 等运行时变量里,越来越难拆。 |
Path B. 利用 ChainCtx |
ChainCtx 生命周期粘在 Chain 状态机上,跨 Chain(DCChains 子链 / SeqChains)传递语义复杂,TFROB-242 已为此打过一轮补丁;让工具状态再来重蹈一遍是反向架构选择。 |
1.2 典型反例:TFNotes 工具¶
设想一个让 LLM 在工作过程中"做笔记,并在下一轮决策时能看到笔记内容"的工具:
T0 user: 我想分析 PDF
T0' LLM: TFNotes.add("待办:先抽取目录")
T1 user: 你刚才记了什么? ← 跨 user input 仍要让 LLM 看到笔记
T1' LLM: 引用 T0' 的笔记决策下一步
要做到 T1 时让 LLM "看到" T0' 写下的笔记,需要工具能在每次 LLM 调用的 prompt 构建阶段主动注入自己的状态视图。这是当前 BaseTool 不具备的能力。
1.3 核心矛盾¶
工具实例长期挂载,但只在被 LLM 决策调用时才说话。状态化的工具天然要求"在 LLM 决策之前先把世界观注入它的 prompt"。
这是一个双向通信缺口:
- 现有:LLM → Tool(run())
- 缺失:Tool → LLM(注入上下文)
二、设计目标¶
| # | 目标 | 解释 |
|---|---|---|
| G1 | 给 BaseTool 一个标准的 prompt 注入通路 | 工具能在每次 LLM 调用时,向指定 *_prompt 段位字段贡献一个 BasePrompt —— 最终参与现有 format_2_str 渲染流水线,与 LLM 配置态的 *_prompt 同等待遇 |
| G2 | 覆写即强制思考 + 显式选择 | 一旦工具覆写 _format_2_prompt,必须声明 prompt_position: tuple[ChatPos \| None, DeskPos \| None] 且至少一边非 None。可双边注入 (ChatPos, DeskPos),也可单边 (ChatPos, None) / (None, DeskPos) —— 沉默是显式选择,不是默认行为。这避免某类 LLM 下"悄悄失效",又不强迫开发者填凑数位 |
| G3 | 零侵入 现有工具 | 现有 30 个 BaseTool 子类(PyIDE 系列 14 / Search 4 / MCP / DSL / Iteration / Recall / ExpandContext / Trafilatura / RetrieveTool / Tool / InvalidTool 等)一行代码不改 |
| G4 | 状态存储完全由工具自治 | 框架不规定状态存哪、生命周期多长。文件 / DB / 内存 / additional_info / 自管 dict —— 工具说了算 |
| G5 | 多工具同位共存 | 同一段位有多个工具贡献时,按本次调用 tools 列表顺序合并到 *_prompt 列表后端,参与同一次渲染循环(不是字符串拼接,是 BasePrompt 列表合并) |
| G6 | 触发面最小化 | 未声明 prompt_position 的工具完全不触发 format_2_prompt 调用;仅当工具同时 ∈ active tools ∧ 已声明 prompt_position 时才参与本轮注入 |
显式不做的事(Non-Goals)¶
- ❌ 不为工具状态提供存储抽象(
StateStore/StateScope都不做)。这是工具的事。 - ❌ 不做去重 / 优先级 / role tagging 等更复杂的注入聚合策略(如
ContextChunk模型)。后续按需再加。 - ❌ 不在本期支持第三类 LLM(如
BaseLLM直接子类、未来VisionLLM/AgentLLM)。tuple[Chat, Desk]可未来扩展为Mapping[Family, Position],本期落点 tuple。 - ❌ 不把
intermediate_msgs/conversation/reformat_input_prompt这类消息流字段或用户输入替换字段纳入合法注入目标。注入目标限定在 LLM 配置态的list[BasePrompt]字段(ChatLLM 的 4 个 system-role 段位 + DeskLLM 的 7 个段位,详见 §五 合法位选择表)。
三、设计方案¶
3.1 BaseTool API 增强:父类框架 format_2_prompt + 子类 _format_2_prompt 双层¶
参考 BaseTool.run / BaseTool._run 已经验证多年的双层模式 —— 父类做"边界统一 + 类型归一化",子类只关心业务返回。
# tfrobot/drive/tool/base.py
from typing import ClassVar, Literal, TYPE_CHECKING
# =====================================================================
# 合法注入位定义
# =====================================================================
# 收口原则(两条铁律 + 一条架构边界):
#
# 铁律 1:禁止注入「用户意图表达位」
# 工具不可参与用户原话/任务目的的表达。一旦允许,工具的封装 bug 会
# 直接污染用户意图,导致 LLM 决策依据是被工具改写过的"用户输入",
# bug 现场极难溯源(用户看不见自己说的话被改了什么)。
#
# 铁律 2:禁止注入「系统维护的历史快照位」
# 历史是系统对 desk/world 的事实记录,工具不可改写历史;否则 desk
# 时间线的因果性被破坏,无法回放、无法 audit。
#
# 架构边界:仅 list[BasePrompt] 字段
# conversation_msgs / intermediate_msgs 是 list[BaseMessage](消息流),
# 不在 LLM "*_prompt" 配置态,架构上本来就不是注入位。
# =====================================================================
ChatLLMPromptPosition = Literal[
"system_msg_prompt",
# ✓ 系统消息位。角色、全局背景、长期记忆等。最常用注入位。
"before_input_msg_prompt",
# ✓ 用户输入前的引导消息。
# 注:紧贴用户输入但不是用户输入本身(来自 LLM 实例配置的模板,
# 消息 role 是 system),所以工具可写。
"after_input_msg_prompt",
# ✓ 用户输入后的补充消息。同 before_input_msg_prompt 的判据。
"after_intermediate_msg_prompt",
# ✓ 中间消息后的总结位。
# ===== 以下为 ChatLLM 不收为注入位的段位(reference 注释,不写入 Literal)=====
#
# ✗ reformat_input_prompt(铁律 1:用户意图表达位)
# 该段的语义是「**替换**用户输入文本」—— 工具一旦写到这里,等于把
# 用户原话改写后再交给 LLM,意图污染最严重。
#
# ✗ conversation_msgs(架构边界)
# list[BaseMessage],不是 list[BasePrompt]。是 LLM 调用 runtime 状态,
# 不在配置态。工具想反映历史可以读 prompt_ctx.conversation 后渲染到合法位。
#
# ✗ intermediate_msgs(架构边界)
# 同上,list[BaseMessage]。工具产生的中间结果该走 ToolReturn → intermediate_msgs
# 的标准链路,不该在 prompt 配置态绕一遍。
]
DeskLLMPromptPosition = Literal[
"system_prompt",
# ✓ 系统消息位。同 ChatLLM.system_msg_prompt 判据。
"instruction_prompt",
# ✓ few-shot 示例区。"经验回放"类工具可注入此位。
"intermediate_prompt",
# ✓ 中间动作位。
"current_desk_screenshot_prompt",
# ✓ 当前环境实时状态。是 live 视图(与 original_desk_screenshot_prompt 的
# "历史快照"语义对偶),工具贴 desk 的额外维度可注入此位。
# ===== 以下为 DeskLLM 不收为注入位的段位(reference 注释,不写入 Literal)=====
#
# ✗ original_desk_screenshot_prompt(铁律 2:系统维护的历史快照位)
# 字段语义明文是「任务初始时的工作环境状态」,由系统在任务起点记录后
# 全程不变。工具改写它 = 改写 desk 历史,破坏因果性、丢失 audit 能力。
#
# ✗ purpose_prompt(铁律 1:用户意图表达位)
# 字段语义明文是「任务目的提示,一般是用户输入或当前任务描述」。
# 工具不可篡改用户当前任务的描述。
#
# ✗ prefix_prompt(铁律 1:用户意图表达位)
# 该段位于所有 prompt 最末尾、紧贴 LLM 输出,是对 LLM 行为的"最后一句话",
# 位置敏感性最高。工具一旦在此注入,会直接劫持 LLM 当前输出形态,
# 即使语义上看似"行事风格前缀",工程上仍按用户意图位对待,禁止工具染指。
]
class BaseTool(TFNeuralModel, ABC):
# === 新增 ClassVar ===
prompt_position: ClassVar[
tuple[ChatLLMPromptPosition | None, DeskLLMPromptPosition | None] | None
] = None
"""
Prompt 注入位。元组形如 (chat_position, desk_position),每一边可为 None 表示该 LLM family 下不注入。
取值约定(覆写 _format_2_prompt 时必须显式设置;__init_subclass__ 强校验):
- None —— 未覆写 _format_2_prompt,零接入(默认)
- (ChatPos, DeskPos) —— 双边注入(多数状态化工具)
- (ChatPos, None) —— 仅 ChatLLM 注入,DeskLLM 下显式沉默
- (None, DeskPos) —— 反之
- (None, None) —— 拒绝:与 prompt_position = None 重复,请改用 None
"""
# === 父类框架方法(子类不应覆写)===
def format_2_prompt(self, prompt_ctx: "PromptContext") -> "BasePrompt | None":
"""
框架方法。在每次 LLM 调用 prompt 构建阶段,由 LLM 注入器对 active tools 调用。
生命周期:
_format_2_prompt (子类业务) → 类型归一化 → BasePrompt | None
- 子类返回 `str` → 自动包成 `NormalizePrompt(template=FStrPromptTemplate(
templates={DEFAULT: <str 字面花括号已转义>}))`,**这是框架内唯一一处自动
转义字面 `{` / `}` 的地方** —— 工具 dev 不必了解 fstring placeholder 规则。
- 子类返回 `BasePrompt`(如自定义 NormalizePrompt / Jinja2 prompt / multimodal prompt)→ 原样透传
- 子类返回 `""` 或 `None` → 框架返回 `None`,注入器跳过该工具贡献
- 子类抛异常 → 框架降级为 `None` + 记 ERROR 日志(保 LLM 调用主链路不挂掉)
关键约束:
- 工具状态存哪由工具自己决定(文件 / DB / 内存 / additional_info / neural 召回均可)。
框架仅承诺 prompt_ctx 里的字段在调用时已完整构建。
- 此方法每次 LLM 调用(前提工具 ∈ active tools)都会被触发,实现需保证幂等且廉价。
昂贵 IO 应在 `_run` / `_async_run` 内 prefetch,`_format_2_prompt` 只读已落地状态。
"""
if self.__class__._format_2_prompt is BaseTool._format_2_prompt:
# 子类未覆写 → 默认无注入
return None
try:
raw = self._format_2_prompt(prompt_ctx)
except Exception as e:
logger.error(
"Tool %s format_2_prompt failed, falling back to None: %s",
self.tool_name, e, exc_info=True,
)
return None
if raw is None or raw == "":
return None
if isinstance(raw, str):
return self._wrap_str_as_normalize_prompt(raw)
if isinstance(raw, BasePrompt):
return raw
raise TFProtocolError(
f"Tool {self.tool_name}._format_2_prompt() must return str | BasePrompt | None, "
f"got {type(raw).__name__}",
protocol="tool_format_2_prompt",
)
@staticmethod
def _wrap_str_as_normalize_prompt(text: str) -> "BasePrompt":
"""
把字面 str 包装为 NormalizePrompt(FStrPromptTemplate)。
★ 框架内唯一一处自动花括号转义点:
字面文本里的 `{` `}` 一旦进 fstring 模板就是 placeholder,会触发 KeyError
(已知踩坑见 memory `feedback_fstring_template_brace_escape.md`)。
本方法负责双倍转义,让工具 dev 写字面 str 时不必关心 fstring 规则。
如果工具想显式控制模板(如 Jinja2 / 多 locale),返回完整 BasePrompt 即可,
不会经过本兜底路径。
"""
from tfrobot.brain.chain.prompt.normalize_prompt import NormalizePrompt
from tfrobot.brain.chain.prompt.template.f_string_template import FStrPromptTemplate
from tfrobot.schema.types import Locale
escaped = text.replace("{", "{{").replace("}", "}}")
return NormalizePrompt(
template=FStrPromptTemplate(
templates={Locale.DEFAULT: escaped},
active_language=Locale.DEFAULT,
)
)
# === 子类业务点(参考 _run)===
def _format_2_prompt(self, prompt_ctx: "PromptContext") -> "str | BasePrompt | None":
"""
子类业务点。与 `_run` 同构设计:父类只做归一化,业务自由返回。
返回值约定:
- `str`:字面注入文本,框架自动转义字面 `{` `}` 后包 `NormalizePrompt(FStrPromptTemplate(templates={DEFAULT: <escaped>}))` —— 工具 dev 无需懂 fstring placeholder 规则
- `BasePrompt`:高级用法 —— 工具想用 i18n / multimodal / 自定义模板时可直接返回 prompt 实例
- `""` / `None`:本次无内容,跳过
默认实现返回 None,零侵入现有工具。
"""
return None
为什么 _format_2_prompt 不传 llm_family / position¶
讨论中的另一选项是 _format_2_prompt(prompt_ctx, llm_family, position) -> ...。最终决策:只传 prompt_ctx。理由:
- 工具自己定义了
prompt_positiontuple,已经把"chat 时去哪、desk 时去哪"显式写在类上;不需要在每次回调里告诉它"现在是 chat 还是 desk"。 - 同一工具在两个 LLM 类型下渲染相同内容是绝大多数情况(笔记、用户偏好、工作记忆都不分 chat/desk)。需要差异化的工具可以从
prompt_ctx.neural自己推断或用 contextvars。 - 接口越简单越不易被滥用。
为什么 format_2_prompt / _format_2_prompt 不是 @abstractmethod¶
会让 13+ 个现有工具(MCP / IDEs / Search / DSL / Iteration / Recall …)全部强制重写一遍空方法,纯破坏面没有任何收益。默认 _format_2_prompt 返回 None + __init_subclass__ 在覆写时强制要求 prompt_position,是更好的边界。
为什么不复用 AdditionalInfoPrompt,而新建 NormalizePrompt¶
仓库里已存在 AdditionalInfoPrompt(tfrobot/brain/chain/prompt/additional_info_prompt.py:43)以及 MemoPrompt / KnowledgePrompt 等若干语义化的 BasePrompt 子类。它们的本质都是把复杂的 PromptContext 简化 —— 各自聚焦于 ctx 的一个语义子集(additional_info / memo / knowledge / ...),让 prompt 撰写人只需要了解局部变量,降低写模板时引用 ctx 路径的长度与重复。
但缺一个最低限度的具体化:当撰写人就是想要"全 ctx 注入模板自己写"时,没有可实例化的 BasePrompt 子类能直接用(BasePrompt 是 ABC)。
NormalizePrompt 就是这一层补全。它不绑定工具语义 —— 工具注入只是它的第一个消费者。
3.1.5 NormalizePrompt 设计¶
# tfrobot/brain/chain/prompt/normalize_prompt.py(新建)
import warnings
from pathlib import Path
from typing import Optional
from pydantic_core import Url
from tfrobot.brain.chain.prompt.base import BasePrompt
from tfrobot.brain.chain.prompt.template.prompt_template import BasePromptTemplate
from tfrobot.schema.brain.chain.prompt.context_vars import PromptContext
from tfrobot.schema.meta.tf_field import TFField, TFFieldMeta
class NormalizePrompt(BasePrompt):
"""
BasePrompt 体系的最低限度具体化。
与 AdditionalInfoPrompt / MemoPrompt / KnowledgePrompt 等不同:
- 那些类的本质是「把复杂的 PromptContext 简化」—— 各自聚焦 ctx 的一个语义子集,
让模板撰写人只需要了解局部变量。
- NormalizePrompt 的本质是「不简化」—— 直接把 ctx.model_dump() 作为 vars
注入 template.render,让撰写人面对全 ctx,自由引用任何字段。
适用场景:
1. BaseTool._format_2_prompt() 返回 str 时的框架兜底(自动转义花括号后包入此类)
2. 任何想直接基于全 ctx 写一段模板的开发者,不愿/不需要新建专用 BasePrompt 子类
"""
template: BasePromptTemplate = TFField(
...,
title="模板",
description="任意 BasePromptTemplate(FStrPromptTemplate / Jinja2Template 等),"
"渲染时拿 ctx.model_dump(mode='json') 作为 vars。",
tf_meta=TFFieldMeta(editable=True, is_independent_storage=True),
)
def format_2_str(self, ctx: PromptContext) -> str:
vars_dict = ctx.model_dump(mode="json")
try:
return self.template.render(vars_dict)
except Exception as e:
warnings.warn(f"NormalizePrompt render failed: {e}")
return ""
def format_2_multimodal(self, ctx: PromptContext) -> tuple[str, dict[str, dict[str, Path | Url | bytes]]]:
vars_dict = ctx.model_dump(mode="json")
try:
return self.template.multimodal_render(vars_dict)
except Exception as e:
warnings.warn(f"NormalizePrompt multimodal render failed: {e}")
return "", {}
约 40 行。覆盖 BasePrompt 两个抽象方法。与现有 AdditionalInfoPrompt 同形(都持有 template),只是不裁剪 ctx。
花括号转义的责任收敛¶
NormalizePrompt 自身不知道字面 / 模板的区别 —— 它永远走 template.render。当工具 _format_2_prompt 返回字面 str 时,框架在 BaseTool._wrap_str_as_normalize_prompt 这一处统一执行 text.replace("{","{{").replace("}","}}")。全仓只有这一处转义:
- 工具 dev 写
_format_2_prompt返回字面 str 时,写正常 Python str,不必懂 fstring 规则 - 工具 dev 写
_format_2_prompt返回 BasePrompt(NormalizePrompt/ 自家其它子类)时,自己控制模板,自己负责转义 NormalizePrompt在被开发者直接实例化时(场景 2),开发者写模板时按 fstring/Jinja2 规范写花括号,与现有AdditionalInfoPrompt等其它 BasePrompt 子类一致 —— 这是"使用模板系统"的代价,不是 NormalizePrompt 的特殊负担
3.2 PromptContext 是什么、不是什么¶
复用现有 PromptContext,不新增字段。工具可读:
| 字段 | 用途 |
|---|---|
user_input.input / additional_info |
当前用户输入(含 current_input.additional_kwargs) |
conversation |
历史会话 |
runtime_vars |
本次 LLM 调用的运行时变量(read-only 视为约定) |
neural |
通过 get_neural_context(tag) 信号召回外部组件状态 |
tools |
当前 active tools(注意已转换为 PromptTool) |
工具想存"跨调用状态"时的几种典型路径(框架不规定,仅提供能力):
1. 写自家的 dict/数据库/文件,在 format_2_prompt 时读出格式化。
2. 通过 prompt_ctx.neural 调外部 Memory 模块取出。
3. 把状态序列化到自家维护的某个文件路径,每次 render 时反序列化。
3.3 LLM 侧注入逻辑¶
由于 format_2_prompt 已经把所有工具贡献归一化为 BasePrompt | None,注入器不做字符串拼接,而是把 BasePrompt 实例临时合并到对应 *_prompt 列表参与现有 format_2_str 循环。
设计原则(v7 复盘)¶
- dynamic_prompts 是短生命周期局部 dict:不挂 PromptContext,不挂 LLM 实例,只在装配方法内活一次;每次 LLM 调用 → 现遍历 tools → 现得 dict → 现合并 → 现弃。
- BaseLLM 不引入 family ClassVar:子类调用端本来就在 family 特定的方法体内,立位传
position_index=0(chat)或1(desk)即可;多一个_llm_family: ClassVar是无意义间接层。 - tools 走入参,不绕 ctx:
BaseLLM.construct_prompt_context(tools=...)已经把 BaseTool list 拿到,装配方法(ChatLLM 的format_to_request_msgs/ 各 DeskLLM adapter 的拼装段)通过传参或 caller 链路直接消费这份 list。
BaseLLM 公共注入器¶
# tfrobot/brain/chain/llms/base.py 新增
class BaseLLM(...):
def _collect_dynamic_prompts(
self,
prompt_ctx: PromptContext,
tools: list[BaseTool] | None,
position_index: int, # 0 = chat, 1 = desk;caller 已在 family 特定方法内,立位传值
) -> dict[str, list[BasePrompt]]:
"""
遍历 active BaseTool,调每个的 format_2_prompt,按 prompt_position 桶聚合。
返回 dict[position_name, list[BasePrompt]],顺序 = tools 入参顺序。
返回值应作为 caller 局部变量即用即弃,不应回写 ctx 或 self。
三层短路:
1. tool.prompt_position is None → 未声明,跳过
2. tool.prompt_position[position_index] is None → 该 family 下显式沉默,连 format_2_prompt 都不调(避免无谓 IO)
3. tool.format_2_prompt(ctx) is None → 状态为空,跳过
"""
if not tools:
return {}
buckets: dict[str, list[BasePrompt]] = {}
for tool in tools:
if not isinstance(tool, BaseTool):
continue
if tool.prompt_position is None:
continue
pos = tool.prompt_position[position_index]
if pos is None:
continue
prompt = tool.format_2_prompt(prompt_ctx) # 父类已归一化为 BasePrompt | None
if prompt is None:
continue
buckets.setdefault(pos, []).append(prompt)
return buckets
ChatLLM 接入点¶
ChatLLM.format_to_request_msgs 是 chat 共通装配方法(落点 tfrobot/brain/chain/llms/chat_llm.py)。S2 落地时给它加 tools 入参(caller complete() 本就持有 tools),段位 4 个独立合并:
# 伪代码示意
class ChatLLM(BaseLLM):
def format_to_request_msgs(self, current_input, prompt_ctx, tools=None):
buckets = self._collect_dynamic_prompts(prompt_ctx, tools, 0) # chat → 立位传 0
# 4 个 chat 合法段位临时合并:
head_system_msg_prompts = self.system_msg_prompt + buckets.get("system_msg_prompt", [])
head_system_msg_content = "\n".join(
[p.format_2_str(prompt_ctx) for p in head_system_msg_prompts]
)
# before_input_msg_prompt / after_input_msg_prompt / after_intermediate_msg_prompt 同款
...
关键不变量:self.system_msg_prompt 等实例字段绝不被修改,只在局部表达式里临时合并 —— 保持 LLM 实例的 prompt 配置态不可变。
DeskLLM 接入点¶
DeskLLM 没有共通 format_to_request_msgs,7 段位拼装在各 adapter(openai_desk / claude_desk / gemini_desk / deepseek_desk / ollama_desk / sglang_desk / openai_gen_desk / tencent_ds_desk)中分散实现。各 adapter 在自己的拼装段调用 _collect_dynamic_prompts(prompt_ctx, tools, 1)(desk → 立位传 1),合并到 4 个 v6 合法段位:
| 段位 | 是否合并 dynamic_prompts |
|---|---|
system_prompt |
✓ |
instruction_prompt |
✓ |
intermediate_prompt |
✓ |
current_desk_screenshot_prompt |
✓ |
original_desk_screenshot_prompt |
✗(铁律 2:历史快照) |
purpose_prompt |
✗(铁律 1:用户意图) |
prefix_prompt |
✗(铁律 1:用户意图) |
S3 第一步 spike 决定是否抽 DeskLLM 共通拼装方法(详见 §10.1 风险 §3);如果不抽,各 adapter 内单独接入。
为什么这样更简洁¶
- 零字符串胶水:注入器只负责把
BasePrompt列表临时合并到*_prompt列表,渲染由现有format_2_str循环统一处理。隔离边界清晰。 - 工具享受 BasePrompt 全套能力:
format_2_multimodal(多模态附件)、additional_kwargs_schema(参数 schema 上抛)、always_render(条件渲染)都自动可用。 - 配置态不可变:临时合并发生在表达式层级,不污染
self.*_prompt,不会跨 call 累积。 - 零中转字段:dynamic_prompts dict 是装配方法局部变量,不挂 ctx 不挂 self;ctx 只持纯渲染数据,self 只持配置态。
- 空值天然跳过:
format_2_prompt返回 None 的工具不进桶,对应*_prompt列表保持原样 —— 不会出现"留空段"。
拼接顺序与去重¶
- 顺序:严格按
tools入参列表顺序(即本次 LLM 调用complete(tools=[...])传入顺序)。 - 去重:在
_collect_dynamic_prompts入口可对tools用id(tool)去重(防同一工具实例被传两次);S2 落地时按需加。
对 reformat_input_prompt 的处理¶
不支持。原因:reformat_input_prompt 的语义是"用模板替换用户输入文本",把工具上下文塞进去会污染用户消息,违背 ChatLLM 7 段式语义。
3.4 工具引用持有:BaseLLM 入参,不绕 PromptContext¶
BaseLLM.construct_prompt_context(tools=...) 已经把 BaseTool list 作为入参拿到,装配方法(ChatLLM 的 format_to_request_msgs / 各 DeskLLM adapter 的拼装段)通过传参或 caller 链路直接消费这份 list,不需要 PromptContext 这一侧再额外持引用。
PromptContext.tools 字段保持现有 list[Annotated[PromptTool | BaseTool, ...]] union 形态 + validate_context 转 PromptTool 视图的现状(用于 prompt 模板内 {{ tools[i].name }} 等引用);工具的 BasePrompt 注入是一条独立的路径,由 LLM 装配阶段持有 tools 入参完成。两条路径不交叉:
- ctx.tools = "工具描述视图"(PromptTool,给 prompt 模板用)
- BaseLLM 局部
tools入参 = "工具实例列表"(BaseTool,给注入器调 format_2_prompt 用)
v6 → v7 演进:v6 设计在 PromptContext 加
_active_tool_instances: list[BaseTool] = PrivateAttr(...)持引用,让 LLM 注入器从 ctx 读。S1 落地时复盘发现:BaseLLM 本来就持有tools入参,绕道 ctx 是没必要的中转,且让 schema 层背上 BaseTool 类型依赖。v7 移除此 PrivateAttr,PromptContext 回归"纯渲染数据"职责。
3.5 校验:prompt_position 是否合法¶
三层校验栈¶
| 层 | 时机 | 触发 | 说明 |
|---|---|---|---|
| L1 类型注解(mypy/pyright 静态期) | IDE / make lint |
写错位串如 "foobar" |
Literal[...] 约束在 type-check 阶段拒绝。这是用户问"Pydantic 不应该天然校验"的最贴近答案 —— 静态类型检查器会在赋值表达式上拒绝非法字面量。 |
L2 __init_subclass__ 运行时校验 |
import / class 定义时 |
子类设置了非法 tuple、或覆写了 _format_2_prompt 但忘填 prompt_position |
复用 BaseTool 现有 __init_subclass__ 添加几行检查(与已有 RegisterMap、span_decorator 在同一处) |
| L3 Pydantic field-level(本期不做) | 实例化时 | n/a | 因为 prompt_position 是 ClassVar,Pydantic 默认完全跳过 ClassVar(这是 Pydantic 的契约,不是缺陷),不会自动跑 Literal 校验。 |
关于"Pydantic 不应该天然校验吗"¶
简短答:对字段(field)会,对
ClassVar不会。细节:
prompt_position写法是ClassVar[tuple[Literal[...], Literal[...]]] = (...)——ClassVar是 PEP 526 定义的"类级标量"标记,Pydantic 看到ClassVar就不会把它登记成 field,自然也不跑 model_validator。要在运行时硬卡住,得用__init_subclass__(10 行内)。如果改成普通字段(去掉 ClassVar)则 Pydantic 会校验 —— 但代价是每个工具实例都会带一份这个 tuple 数据,且子类无法用
ClassVar = ...那种"类级常量"语义表达;这与现有name/description/params_schema等所有"工具元数据用 ClassVar"的现有约定背道而驰。所以选ClassVar+__init_subclass__校验。
init_subclass 校验代码¶
# base.py 中 __init_subclass__ 内追加(约 15 行)
# 与 §3.1 ChatLLMPromptPosition / DeskLLMPromptPosition Literal 严格同步
# v6 起:用户意图位 + 历史快照位 + 非 list[BasePrompt] 字段全部排除
_VALID_CHAT_POSITIONS = frozenset({
"system_msg_prompt", "before_input_msg_prompt",
"after_input_msg_prompt", "after_intermediate_msg_prompt",
# 排除:reformat_input_prompt(用户意图位)/ conversation_msgs / intermediate_msgs(非 list[BasePrompt])
})
_VALID_DESK_POSITIONS = frozenset({
"system_prompt", "instruction_prompt",
"intermediate_prompt", "current_desk_screenshot_prompt",
# 排除:original_desk_screenshot_prompt(历史快照)/ purpose_prompt(用户意图位)/ prefix_prompt(用户意图位)
})
@classmethod
def _validate_prompt_position(cls) -> None:
overrides_render = cls._format_2_prompt is not BaseTool._format_2_prompt
if not overrides_render:
# 未覆写 _format_2_prompt 的工具不需要 prompt_position
if cls.prompt_position is not None:
raise TypeError(
f"{cls.__name__} 设置了 prompt_position 但未覆写 _format_2_prompt(),无意义"
)
return
if cls.prompt_position is None:
raise TypeError(
f"{cls.__name__} 覆写了 _format_2_prompt() 但未声明 prompt_position;"
f"必须填 (chat_pos | None, desk_pos | None),至少一边非 None"
)
chat_pos, desk_pos = cls.prompt_position
if chat_pos is None and desk_pos is None:
raise ValueError(
f"{cls.__name__}.prompt_position = (None, None) 与 prompt_position = None 语义重复。"
f"如不打算注入,请去掉 _format_2_prompt 覆写并把 prompt_position 设回 None"
)
if chat_pos is not None and chat_pos not in _VALID_CHAT_POSITIONS:
raise ValueError(f"{cls.__name__}.prompt_position[0]={chat_pos!r} 非法 ChatLLM 段位")
if desk_pos is not None and desk_pos not in _VALID_DESK_POSITIONS:
raise ValueError(f"{cls.__name__}.prompt_position[1]={desk_pos!r} 非法 DeskLLM 段位")
注意:判定"是否覆写"用 cls._format_2_prompt is not BaseTool._format_2_prompt,而不是 cls.format_2_prompt。因为 format_2_prompt 是父类框架方法、不应该被覆写;用户的覆写信号在 _format_2_prompt。
3.6 范例:TFNotes 工具骨架¶
简单情况 —— _format_2_prompt 返回 str(最常见)¶
# tfrobot/drive/tool/tf_notes/tool.py
from typing import ClassVar
from pydantic import TypeAdapter, BaseModel
from tfrobot.drive.tool.base import BaseTool
from tfrobot.schema.brain.chain.prompt.context_vars import PromptContext
class _AddNoteParams(BaseModel):
note: str
class TFNotesTool(BaseTool):
name: ClassVar[str] = "tf_notes"
description: ClassVar[str] = "记录工作笔记,自动注入到下一轮 LLM 决策的 prompt"
params_schema: ClassVar = TypeAdapter(_AddNoteParams)
# ★ 新契约:ChatLLM 下注入到 system_msg_prompt(角色/记忆位),
# DeskLLM 下推荐 system_prompt(同语义)。
# 注:v6 起 purpose_prompt / prefix_prompt 被铁律 1 排除(用户意图位);
# original_desk_screenshot_prompt 被铁律 2 排除(历史快照),所以
# DeskLLM 这边没有"任务目的"型注入位可选 —— 笔记更适合放 system_prompt。
prompt_position: ClassVar = ("system_msg_prompt", "system_prompt")
# 状态存储——工具自己说了算
_notes: list[str] = [] # 简化示意;生产可换 file/DB/conv-scoped dict
def _run(self, *, note: str) -> str:
self._notes.append(note)
return f"已记录笔记 #{len(self._notes)}"
async def _async_run(self, *, note: str) -> str:
return self._run(note=note)
# 子类只需返回 str,父类 format_2_prompt 自动:
# 1. 转义字面花括号
# 2. 包入 NormalizePrompt(FStrPromptTemplate(templates={DEFAULT: <escaped str>}))
# 工具 dev 写正常 Python str 即可,无需懂 fstring placeholder 规则
def _format_2_prompt(self, prompt_ctx: PromptContext) -> str | None:
if not self._notes:
return None
body = "\n".join(f"- {n}" for n in self._notes)
# 即使笔记里出现 "代码 {x: 1}" 这类字面花括号,框架兜底会自动 {{ }} 转义
return f"# 工作笔记(截至当前轮)\n\n{body}"
高级情况 —— _format_2_prompt 返回 BasePrompt(i18n / 自定义模板)¶
需要按 locale 渲染或自定义模板时,可直接返回 NormalizePrompt(享受全 ctx 字段)或自家其它 BasePrompt 子类:
def _format_2_prompt(self, prompt_ctx: PromptContext) -> "BasePrompt | None":
if not self._notes:
return None
notes_text = "\n".join(f"- {n}" for n in self._notes)
# 直接用 NormalizePrompt:写模板时拿全 ctx 作为 vars
# 注意:开发者直接写模板时,字面花括号要自己双倍写(与 AdditionalInfoPrompt 等同规则)
return NormalizePrompt(
template=FStrPromptTemplate(
templates={
Locale.EN: f"# Working notes (user={{user_input[input]}})\n\n{notes_text}",
Locale.ZH: f"# 工作笔记(用户={{user_input[input]}})\n\n{notes_text}",
},
active_language=Locale.DEFAULT,
),
)
单边注入示例 —— 仅 ChatLLM 下注入¶
某些工具的注入语义只对 ChatLLM 有意义(如笔记类、对话偏好类),DeskLLM(任务执行环境)下注入笔记反而干扰:
class ChatOnlyMemoTool(BaseTool):
name: ClassVar[str] = "chat_memo"
description: ClassVar[str] = "对话备忘,仅在 ChatLLM 下注入"
# ★ 单边注入:DeskLLM 下显式沉默
prompt_position: ClassVar = ("system_msg_prompt", None)
def _format_2_prompt(self, ctx: PromptContext) -> str | None:
return self._memo or None
ChatOnlyMemoTool 实例若同时被注册到 ChatLLM Robot 与 DeskLLM Robot,前者会注入到 system_msg_prompt,后者甚至不调用 _format_2_prompt(注入器在 §3.3 第二层短路就跳过)。
LLM 调用时 after_input_msg_prompt 段最终内容(ChatLLM 情景)¶
[原 after_input_msg_prompt 渲染结果]
# 工作笔记(截至当前轮)
- 待办:先抽取目录
- 用户更关心方法论而非数字
四、变更清单¶
v7:原 C3(PromptContext 双视图)已删除(不需要中转字段);C4 签名变为
_collect_dynamic_prompts(prompt_ctx, tools, position_index);C5/C6 改为消费"装配方法局部 dict"。
| # | 文件 | 改动 | 性质 | 状态 |
|---|---|---|---|---|
| C1 | tfrobot/drive/tool/base.py |
新增 ChatLLMPromptPosition / DeskLLMPromptPosition Literal、prompt_position ClassVar、父类框架方法 format_2_prompt() + 子类业务点 _format_2_prompt()、__init_subclass__ 中 _validate_prompt_position() |
加 | ✓ S1 已落地 |
| C2 | tfrobot/brain/chain/prompt/normalize_prompt.py |
新建 NormalizePrompt(BasePrompt):BasePrompt 体系最低限度具体化(持有 template,全 ctx 注入),约 50 行(参见 §3.1.5) |
加 | ✓ S1 已落地 |
| ~~C3~~ | ~~tfrobot/schema/brain/chain/prompt/context_vars.py~~ |
~~PromptContext 加 _active_tool_instances~~ |
删 | ✗ v7 撤回(§3.4) |
| C4 | tfrobot/brain/chain/llms/base.py |
新增 _collect_dynamic_prompts(prompt_ctx, tools, position_index: int) -> dict[str, list[BasePrompt]] —— 公共注入器;caller 立位传 0 (chat) / 1 (desk);返回 dict 应作为 caller 局部变量即用即弃 |
加 | ✓ S2 已落地 |
| C5 | tfrobot/brain/chain/llms/chat_llm.py |
format_to_request_msgs 加 tools 入参;进入时 buckets = self._collect_dynamic_prompts(prompt_ctx, tools, 0);4 个 system 段位渲染由 self.X 改为 self.X + buckets.get("X", []) 临时合并;complete() Phase 3 调用处补传 tools |
改 | ✓ S2 已落地 |
| C6 | 各 DeskLLM adapter(tfrobot/brain/chain/llms/generation_llms/desk_llm/*.py) |
各 adapter 在自己的拼装段调 self._collect_dynamic_prompts(prompt_ctx, tools, 1);4 个 v6 合法段位同款合并;S3 spike 后决策:8 adapter 各自接入(无共通装配方法可抽 —— OpenAI Responses instructions / Gemini Part / DeepSeek/Ollama suffix 各自不同) |
改 | ✓ S3 已落地(8 adapter) |
| C7 | tfrobot/brain/chain/prompt/__init__.py |
导出 NormalizePrompt |
改 | ✓ S1 已落地 |
| C8 | tests/unit_tests/drive/tool/test_base_tool_context.py |
新建:覆盖 §3.5 三层校验、_format_2_prompt 六态归一化、异常降级、prompt_position 非法值拒绝、字面花括号自动转义 |
加 | ✓ S1 已落地(17 cases) |
| C9 | tests/unit_tests/brain/chain/llms/test_tool_prompt_injection.py |
新建:ChatLLM/DeskLLM 各自一组集成(mock LLM)测试,覆盖临时合并不污染实例字段、tools 入参顺序、空跳过、单边沉默 | 加 | ✓ S2 ChatLLM 部分已落地(9 cases)+ ✓ S3 DeskLLM 部分已落地(8 cases,含 D1-D8 + 铁律 1/2 import-time 拒) |
| C10 | tests/unit_tests/brain/chain/prompt/test_normalize_prompt.py |
新建:NormalizePrompt.format_2_str / format_2_multimodal 单元 + 全 ctx 字段在模板里可引用 + 渲染失败兜底 |
加 | ✓ S1 已落地(10 cases) |
| C11 | docs/drive/tools/prompt-injection.md |
新建:开发者指南(写工具时如何用 _format_2_prompt + 字符串 vs BasePrompt 两条路径选择) |
加 | S4 落地 |
| C12 | tfrobot/schema/exceptions.py |
PROTOCOLS Literal 新增 tool_format_2_prompt —— BaseTool._format_2_prompt 返回非法类型的协议名 |
改 | ✓ S1 已落地(C1 副产物) |
零侵入承诺验证:现有 30 个 BaseTool 子类(PyIDE 14 / Search 4 / MCP / DSL / Iteration / Recall / ExpandContext / Trafilatura / RetrieveTool / Tool / InvalidTool 等)全部不涉及 prompt_position / _format_2_prompt 改动 —— S1 落地后 git diff --stat 上 0 个工具源文件被触动。
五、合法位选择的取舍¶
5.1 收口判据(两条铁律 + 一条架构边界)¶
| 判据 | 内容 | 后果 |
|---|---|---|
| 铁律 1 | 禁止注入「用户意图表达位」:reformat_input_prompt(替换用户输入)/ purpose_prompt(任务目的)/ prefix_prompt(紧贴 LLM 输出的最末尾行为指令) | 工具一旦写到这些位 = 篡改用户原话/任务描述/最后行为指令;bug 时意图被改难以追溯 |
| 铁律 2 | 禁止注入「系统维护的历史快照位」:original_desk_screenshot_prompt(任务初始 desk 状态) | 工具改写历史 = 破坏 desk 时间线因果性、丢失 audit 能力 |
| 架构边界 | 仅 list[BasePrompt] 字段:conversation_msgs / intermediate_msgs 是 list[BaseMessage],不在配置态 |
工具想反映历史/中间结果 → 走标准链路(ToolReturn → intermediate_msgs),不要绕道 prompt 配置态 |
5.2 各段位最终判定¶
| 段位 | LLM | 收/不收 | 判据 / 用途 |
|---|---|---|---|
system_msg_prompt |
ChatLLM | ✓ | 角色、全局背景、长期记忆。最常用注入位 |
before_input_msg_prompt |
ChatLLM | ✓ | 用户输入前的引导(消息 role=system) |
after_input_msg_prompt |
ChatLLM | ✓ | 用户输入后的补充现状 |
after_intermediate_msg_prompt |
ChatLLM | ✓ | 中间消息后的总结位 |
reformat_input_prompt |
ChatLLM | ✗ | 铁律 1:替换用户输入文本,工具污染等于篡改用户原话 |
conversation_msgs |
ChatLLM | ✗ | 架构边界:list[BaseMessage],非 list[BasePrompt] |
intermediate_msgs |
ChatLLM | ✗ | 架构边界:同上。工具中间结果走 ToolReturn 链路 |
system_prompt |
DeskLLM | ✓ | 系统消息位 |
instruction_prompt |
DeskLLM | ✓ | few-shot 区,"经验回放"类工具 |
intermediate_prompt |
DeskLLM | ✓ | 中间动作位 |
current_desk_screenshot_prompt |
DeskLLM | ✓ | 当前环境实时状态(live 视图,与 original 历史快照对偶) |
original_desk_screenshot_prompt |
DeskLLM | ✗ | 铁律 2:系统维护的初始环境快照,工具改写 = 改写历史 |
purpose_prompt |
DeskLLM | ✗ | 铁律 1:任务目的,是用户意图核心 |
prefix_prompt |
DeskLLM | ✗ | 铁律 1:紧贴 LLM 输出的最末尾行为指令,位置敏感性最高 |
ChatLLM 4 个合法位 + DeskLLM 4 个合法位。Literal[...] 在 §3.1 已按此精确收口,inline 注释保留两条铁律的判据,给后来开发者扩展时一个清晰的 bar。
5.3 如果未来要新增 LLM 段位¶
当 LLM 实现者新增一个 *_prompt: list[BasePrompt] 字段时,本 PRD 的"是否纳入注入位"决策流:
1. 该段是否承载用户原话/任务目的/最末尾行为指令?
是 → 铁律 1 → 不收
2. 该段是否是系统维护的历史快照?
是 → 铁律 2 → 不收
3. 该段是否是 list[BasePrompt]?
否 → 架构边界 → 不收
4. 否则 → 收,加 inline 注释说明语义
六、并发与生命周期¶
- 并发:
format_2_prompt在 LLM 调用线程中同步调用一次。工具自己负责 thread-safe(如多 conversation 并发、Robot 多副本场景)。框架对_active_tool_instances不做锁。 - 同步/异步:本期只暴露 sync
format_2_prompt。理由: - 90% 状态读取(内存 / 本地文件)天然 sync。
- 需要 async IO 的工具应在
_async_run内 prefetch 到本地状态、render 时纯读。 - 如未来证伪此假设,再加
async def async_format_2_prompt()—— 默认 delegate 到 sync 是无破坏面的扩展。 - 失败处理:
format_2_prompt抛异常时降级为空字符串 + 记 ERROR 日志,不阻塞 LLM 调用(这是用户体验底线 —— 工具状态注入失败不能让对话挂掉)。
七、可观测性¶
最小集成:
- 在
_collect_tool_context_buckets内打 OTel attribute: tool_context.tools_total:本次调用中声明 prompt_position 的工具数tool_context.tools_rendered:实际产生非空贡献的工具数tool_context.bytes:所有 render 文本累计字节数tool_context.position_<name>.tools:各位贡献者数(仅非零位上报)
不做:单工具 render 内容入 trace(隐私 + 成本敏感)。
八、测试方案¶
8.1 BaseTool 单元(test_base_tool_context.py)¶
| Case | 验证 |
|---|---|
默认 BaseTool 子类不写 prompt_position 且不覆写 _format_2_prompt() → import OK |
零侵入 |
子类覆写 _format_2_prompt() 但未填 prompt_position → import 抛 TypeError |
L2 校验 |
子类填了 prompt_position=("WRONG", "system_prompt") → import 抛 ValueError(chat_pos 非法) |
L2 校验 |
子类填了 prompt_position=("system_msg_prompt", "purpose_prompt") → import 抛 ValueError(v6 铁律:purpose_prompt 是用户意图位) |
L2 + 铁律 1 |
子类填了 prompt_position=("system_msg_prompt", "original_desk_screenshot_prompt") → import 抛 ValueError(v6 铁律:original 是历史快照) |
L2 + 铁律 2 |
子类填了 prompt_position=(None, None) → import 抛 ValueError(与 None 重复) |
L2 校验 |
子类填了 prompt_position=("system_msg_prompt", None) → import OK;ChatLLM 路径注入,DeskLLM 路径不调 format_2_prompt |
单边注入 |
子类填了 prompt_position=(None, "system_prompt") → import OK;DeskLLM 路径注入,ChatLLM 路径不调 format_2_prompt |
单边注入(反向) |
子类填了 prompt_position 但没覆写 _format_2_prompt → import 抛 TypeError(多余声明) |
L2 校验 |
默认 format_2_prompt() 返回 None(基类未覆写 _format_2_prompt) |
默认行为 |
_format_2_prompt 返回 "hello" → format_2_prompt 返回 NormalizePrompt(template=FStrPromptTemplate(templates={DEFAULT: "hello"})) |
str 归一化 |
_format_2_prompt 返回 "代码 {x: 1}"(含字面花括号)→ 包入 NormalizePrompt 后 format_2_str(ctx) 不抛 KeyError,输出原文 |
自动转义不变 |
_format_2_prompt 返回 "" → format_2_prompt 返回 None |
空跳过 |
_format_2_prompt 返回 None → format_2_prompt 返回 None |
None 透传 |
_format_2_prompt 返回 BasePrompt 子类实例 → format_2_prompt 原样透传 |
BasePrompt 透传 |
_format_2_prompt 抛异常 → format_2_prompt 返回 None + ERROR 日志 |
异常降级 |
_format_2_prompt 返回非法类型(如 dict)→ 抛 TFProtocolError |
类型契约 |
8.2 LLM 注入集成(test_tool_prompt_injection.py,使用 MockBaseLLM 路径)¶
| Case | 验证 |
|---|---|
| 单工具 chat / 单工具 desk / 各注入到对应位 | G1 双适配 |
工具 _format_2_prompt 返回 None → 该位 prompt 列表保持原样 |
G6 空跳过 |
同位多工具 → 临时合并到 *_prompt 列表后,按 active tools 顺序参与 format_2_str 循环 |
G5 顺序 |
工具不在 active tools 列表 → format_2_prompt 不被调用(spy 验证) |
G6 触发面 |
_format_2_prompt 抛异常 → 该工具被跳过,其他工具仍正常注入 |
§六 失败处理 |
同时存在 system_msg_prompt + after_input_msg_prompt 工具 → 各自落到对应段 |
桶聚合 |
关键不变量:注入前后 self.system_msg_prompt / self.purpose_prompt 等实例字段长度不变 |
配置态不可变 |
工具返回 BasePrompt(含 format_2_multimodal 的多模态附件)→ 附件正常被 ChatLLM 收集 |
BasePrompt 全套能力 |
九、分阶段实施¶
| 阶段 | Story | 范围 | 验收 | 状态 |
|---|---|---|---|---|
| P1 | TFROB-464 (S1) | C1 + C2 + C7 + C8 + C10 + C12(BaseTool 双层 + NormalizePrompt + 协议名 + 两组单元测试) | make unit-test 全绿;现有 30 个 BaseTool 子类零改动;NormalizePrompt 全 ctx 字段可在模板里引用 + 字面花括号自动转义不抛 KeyError;NormalizePrompt 单元覆盖率 ≥ 90% |
✓ 已落地(NormalizePrompt 100% cov,27 cases 全过) |
| P2 | TFROB-465 (S2) | C4 + C5 + C9 ChatLLM 部分(公共注入器 + ChatLLM 4 段位接入 + 集成测试) | ChatLLM 路径完整可用;临时合并不污染 self.system_msg_prompt 等实例字段 |
✓ 已落地(9 cases;adapter 0 改动;make lint 全过) |
| P3 | TFROB-466 (S3) | C6 + C9 DeskLLM 部分(spike 接入点决策 + 各 adapter 4 段位接入 + 集成测试) | DeskLLM 路径完整可用;接入点先小调研再实施(§十 风险 §3) | ✓ 已落地(spike → 8 adapter 各自接入;8 cases;make unit-test 5087 全过;make lint 0 告警) |
| P4 | TFROB-467 (S4) | C11 + 第一个生产工具落地(TFNotes 或等价物) | 端到端联调(跨 user input 笔记可见);开发者指南文档 | 待落地 |
十、风险与未决¶
10.1 已识别风险¶
| 风险 | 缓解 |
|---|---|
注入器在 format_to_request_msgs 中加重 method 复杂度 |
把注入器抽成 _collect_tool_prompt_buckets 单方法(C4),主流程只多一次 dict 查与 list 拼接 |
format_2_prompt 静默捕获异常可能掩盖工具 bug |
必须打 ERROR 日志 + OTel exception event;不上抛仅在"prompt 段位拼装"阶段 |
_active_tool_instances 字段被外部 Prompt 误读 |
加 exclude=True + 文档约定;不公开访问 |
ChatLLM 子类(OpenAI / Anthropic / DeepSeek 等)有自定义 _assemble_api_request |
注入应在 format_to_request_msgs 阶段(公共上游),不影响下游 reformat。已实测 chat_llm.py 的 7 段式装配是基类逻辑,子类只 override _assemble_api_request 与 reformat —— 选址正确。 |
_format_2_prompt 返回非 str / BasePrompt / None 异常类型 |
父类 format_2_prompt 在归一化阶段抛 TFProtocolError(protocol="tool_format_2_prompt"),与现有 tool_return_schema 同形 |
工具返回的 BasePrompt 子类用了 FStrPromptTemplate 但 placeholder 与 format_to_request_msgs 现场 ctx 不匹配 |
这是工具自家模板设计问题,框架层不兜底;C11 开发者指南显式提示"返回 BasePrompt 时由你保证模板可渲染"。 |
10.2 待审议(请用户拍板)¶
v7:原第 4 条(
_active_tool_instances命名)随 §3.4 撤回而失效;第 1/2/7 条 S1 落地时按倾向选项实施。
- ~~
NormalizePrompt命名~~ ✓ 已采纳:沿用NormalizePrompt,与AdditionalInfoPrompt/MemoPrompt形成"语义化裁剪 ↔ 不裁剪"的对称。 - ~~是否在
NormalizePrompt上保留always_render字段~~ ✓ 已采纳:不保留;try/except 兜底返回""足够。 - TFNotes 是否作为本 PRD 配套样板 一并落地,还是单独起 ticket?已采纳:拆 TFROB-467 (S4) 同 Epic 子 Story。
- ~~
_active_tool_instances命名~~ ✗ v7 撤回此设计(§3.4),整个字段不存在。 - 是否要把
system_msg_prompt、after_input_msg_prompt等合法位字面量集中到tfrobot/schema/brain/chain/prompt/positions.py作为单一权威源(避免BaseTool与ChatLLM双方各写一份)。倾向集中到 schema 模块下;S2/S3 落地时若发现 ChatLLM/DeskLLM 段位字面量需要外露给注入逻辑用,再做集中。 - DeskLLM 7 段位的注入接入点 DeskLLM 不像 ChatLLM 有统一
format_to_request_msgs,各 adapter 拼装路径分散 —— S3 第一步 spike 决定是否先抽 DeskLLM 共通拼装方法再注入。视调研结果决定 P3 工作量。 - ~~父类框架方法的命名~~ ✓ 已采纳:
format_2_prompt+ 子类_format_2_prompt,与run+_run同构。 - dynamic_prompts 第二个 provider 是否真会出现(比如 Memory 想动态贴长期记忆段)?如真出现,把"BaseLLM 局部 dict"演化为更显式的 ProviderRegistry 集合再合并;本期不抽。
十一、附录¶
A. 与 Intermediate Trace 的关系¶
intermediate_trace 解决"跨 Chain 把执行过程结构化展开给 LLM 看"的问题,注入位是 conversation 与 intermediate_msgs 之间的动态展开。本 PRD 解决"跨 LLM 调用让工具主动注入自己的 world view"的问题,注入位是 system-role prompt 段位。两者正交:trace 是消息流维度,本 PRD 是 prompt 配置态维度。
B. 与 runtime_vars 的关系¶
runtime_vars 是单次 LLM 调用内的 key-value 槽,生命周期 = 单次 complete()。本 PRD 给的是工具自治状态 → prompt 注入的标准通路;工具如果想用 runtime_vars 作为本次注入的中转可以,但跨调用持久化是工具自己的事,不是 runtime_vars 的事。
C. 与 ChainCtx 的关系¶
ChainCtx 是 Chain 状态机内的运行时上下文,与 LLM prompt 拼装位完全无关。本 PRD 不依赖、不修改、不撤销 ChainCtx。已有把状态塞 ChainCtx 的工具如果想迁移到新通路,是一次自愿重构,不强制。