工具 Prompt 段位注入¶
「LLM 怎么知道这个工具该怎么用?」—— 大部分情况下,description 与 params_schema 就够。但有些工具是「有状态、需要给 LLM 加背景」的:知识库召回工具想告诉 LLM「当前命中了哪些片段」,记忆工具想插入「用户的长期偏好」,IDE 工具想告诉 LLM「当前打开的文件是 X」。这些信息不属于「调用结果」,而是「LLM 推理前需要看到的上下文」。
本页讲清楚工具如何用 prompt_position + _format_2_prompt() 把自己的 Prompt 插入到 LLM 消息流的指定段位 —— 即 PRD docs/proposals/base-tool-context-rendering.md 在用户侧的落地说明。
设计理念¶
工具 Prompt 注入面临三个矛盾:
- 不能改用户意图:工具的封装 bug 不能污染用户原话或任务目的,否则 LLM 决策依据被悄悄改写、bug 现场不可溯源。
- 不能改历史快照:
conversation_msgs与intermediate_msgs是消息流,承载因果性,不属于「工具可配置 prompt 位」。 - 不能强制:30+ 已有工具不可能为了「Prompt 注入能力」一夜全部覆写空方法。
解法是「覆写即强制思考 + 显式选择」:
- 默认不注入。工具不写
_format_2_prompt→ 完全零侵入。 - 一旦覆写
_format_2_prompt,必须同时声明prompt_position元组((ChatPos | None, DeskPos | None)),明确告诉框架「我要插哪个段位」。 prompt_position取值范围被 Literal 锁死,违反铁律的段位(如 DeskLLM 的purpose_prompt、original_desk_screenshot_prompt)在 import 时即被__init_subclass__拒绝。
核心概念¶
双层 API:format_2_prompt 与 _format_2_prompt¶
与 run / _run 同构 —— 父类做归一化,子类只关心业务返回:
| 方法 | 谁实现 | 职责 |
|---|---|---|
format_2_prompt(prompt_ctx) |
BaseTool(子类不要覆写) |
调子类 _format_2_prompt,归一化六种返回(str / BasePrompt / "" / None / 异常 / 非法类型) |
_format_2_prompt(prompt_ctx) |
子类覆写 | 返回 str / BasePrompt / None,决定本工具本次注入什么内容 |
调用链:
LLM 注入器(每次 LLM 调用前)
└── tool.format_2_prompt(prompt_ctx)
├── 子类未覆写 _format_2_prompt → 直接 return None(性能短路)
├── 子类返回 str → 自动转义字面 { } → NormalizePrompt(FStrPromptTemplate)
├── 子类返回 BasePrompt 子类 → 原样透传
├── 子类返回 "" / None → 框架返回 None(本轮工具不贡献 prompt)
├── 子类抛异常 → 降级为 None + ERROR 日志(主链路不挂掉)
└── 子类返回非法类型 → 抛 TFProtocolError(protocol="tool_format_2_prompt")
prompt_position 取值表¶
prompt_position 是一个 ClassVar[tuple[ChatLLMPromptPosition | None, DeskLLMPromptPosition | None] | None],两个槽分别对应 ChatLLM 与 DeskLLM 家族下的注入段位。
ChatLLM 合法位(来自 tfrobot/drive/tool/base.py:62-75):
| 位 | 说明 |
|---|---|
system_msg_prompt |
系统消息位。角色、全局背景、长期记忆。最常用注入位 |
before_input_msg_prompt |
用户输入前的引导消息(系统角色,来自 LLM 实例配置模板) |
after_input_msg_prompt |
用户输入后的补充消息 |
after_intermediate_msg_prompt |
中间消息后的总结位 |
DeskLLM 合法位(来自 tfrobot/drive/tool/base.py:77-90):
| 位 | 说明 |
|---|---|
system_prompt |
系统消息位 |
instruction_prompt |
few-shot 示例区。「经验回放」类工具可注入此位 |
intermediate_prompt |
中间动作位 |
current_desk_screenshot_prompt |
当前环境实时状态(live 视图) |
被铁律拒绝的位(写了会在 __init_subclass__ 抛 ValueError):
reformat_input_prompt(用户意图位)purpose_prompt(用户意图位)prefix_prompt(用户意图位,紧贴 LLM 输出的最末尾行为指令)original_desk_screenshot_prompt(历史快照位,工具改写 = 改写历史)
prompt_position 取值组合¶
prompt_position 值 |
语义 |
|---|---|
None |
默认。未覆写 _format_2_prompt,零接入 |
(ChatPos, DeskPos) |
双边注入:ChatLLM 与 DeskLLM 都参与 |
(ChatPos, None) |
单边注入:仅 ChatLLM 注入;DeskLLM 路径下不会调 _format_2_prompt(不浪费 IO) |
(None, DeskPos) |
单边注入:仅 DeskLLM 注入 |
(None, None) |
拒绝:与 None 重复,import 时抛 ValueError |
「沉默是显式选择,不是默认行为」—— 这避免某类 LLM 下「工具悄悄失效」,又不强迫开发者填凑数位。
校验栈:错误在 import 时暴露¶
BaseTool.__init_subclass__ 调 _validate_prompt_position()(tfrobot/drive/tool/base.py:213-254)做五件事:
- 未覆写
_format_2_prompt但声明了prompt_position→TypeError(多余声明) - 覆写了
_format_2_prompt但未声明prompt_position→TypeError(覆写即强制思考) prompt_position = (None, None)→ValueError(与None重复)chat_pos不在_VALID_CHAT_POSITIONS→ValueError(含铁律 1 拒绝位)desk_pos不在_VALID_DESK_POSITIONS→ValueError(含铁律 1/2 拒绝位)
也就是说,只要 import 通过,工具的 Prompt 注入声明就一定合法。运行时不需要再防御。
编写流程¶
简单情况:返回字面 str¶
最常见用例 —— 工具想插入一段固定背景文本:
from typing import ClassVar, Any
from tfrobot.drive.tool.base import BaseTool
class TimezoneHintTool(BaseTool):
name: ClassVar[str] = "timezone_hint"
description: ClassVar[str] = "Read current timezone (no-op demo)."
params_schema: ClassVar[None] = None
prompt_position: ClassVar = ("system_msg_prompt", "system_prompt")
def _format_2_prompt(self, prompt_ctx) -> str | None:
return "当前用户所在时区:Asia/Shanghai (UTC+8)。涉及时间的问题请按此时区作答。"
def _run(self, *args: Any, **kwargs: Any):
return "noop"
async def _async_run(self, *args: Any, **kwargs: Any):
return self._run(*args, **kwargs)
效果:
- ChatLLM 调用时,该 prompt 被注入到
system_msg_prompt列表 - DeskLLM 调用时,该 prompt 被注入到
system_prompt列表 - 字面
{/}会被框架自动双倍转义(全仓唯一一处转义点,工具开发者不必学 fstring placeholder 规则)
单边注入:仅 ChatLLM¶
工具只对某一类 LLM 有意义时,把另一边显式置为 None:
from typing import ClassVar, Any
from tfrobot.drive.tool.base import BaseTool
class ChatOnlyMemoTool(BaseTool):
name: ClassVar[str] = "chat_only_memo"
description: ClassVar[str] = "Memo only meaningful for ChatLLM family."
params_schema: ClassVar[None] = None
prompt_position: ClassVar = ("system_msg_prompt", None)
def _format_2_prompt(self, prompt_ctx) -> str | None:
return "(仅 ChatLLM 路径下注入的 memo)"
def _run(self, *args: Any, **kwargs: Any):
return "noop"
async def _async_run(self, *args: Any, **kwargs: Any):
return self._run(*args, **kwargs)
DeskLLM 路径下,注入器看到 prompt_position[1] is None 会直接跳过,连 _format_2_prompt 都不会调。
高级情况:返回 BasePrompt¶
需要 i18n / multimodal / 自定义模板(如 Jinja2)时,直接返回完整 BasePrompt 实例:
from typing import ClassVar, Any
from tfrobot.brain.chain.prompt.normalize_prompt import NormalizePrompt
from tfrobot.brain.chain.prompt.template.f_string_template import FStrPromptTemplate
from tfrobot.drive.tool.base import BaseTool
from tfrobot.schema.types import Locale
class I18nGreetingTool(BaseTool):
name: ClassVar[str] = "i18n_greeting"
description: ClassVar[str] = "Inject locale-aware greeting prompt."
params_schema: ClassVar[None] = None
prompt_position: ClassVar = ("system_msg_prompt", "system_prompt")
def _format_2_prompt(self, prompt_ctx):
return NormalizePrompt(
template=FStrPromptTemplate(
templates={
Locale.DEFAULT: "Greet the user politely.",
Locale.ZH_CN: "请用中文礼貌地问候用户。",
},
active_language=Locale.ZH_CN,
)
)
def _run(self, *args: Any, **kwargs: Any):
return "noop"
async def _async_run(self, *args: Any, **kwargs: Any):
return self._run(*args, **kwargs)
返回 BasePrompt 时不会经过字面花括号兜底;工具自己掌控模板,自己负责处理转义。
运行时行为¶
何时触发 format_2_prompt¶
注入器(在 LLM 调用 prompt 构建阶段,由 ChatLLM / DeskLLM 各自的 _collect_dynamic_prompts 触发)会遍历本次调用的 active tools,对每个工具按以下顺序短路:
1. tool.prompt_position is None? → 跳过(未声明)
2. tool.prompt_position[position_index] is None? → 跳过(该 family 下显式沉默)
3. tool.format_2_prompt(prompt_ctx) -> prompt
4. prompt is None? → 跳过(本次无内容)
5. prompt 追加到对应段位 prompt 列表,临时合并到 LLM 该位 prompt
position_index 由 LLM family 决定(ChatLLM 取 [0],DeskLLM 取 [1])。注意 「未声明」与「显式沉默」短路位置不同 —— 后者会先解开 tuple 才决定跳过,前者连 tuple 都不取。
幂等与廉价约束¶
_format_2_prompt 在每次 LLM 调用都会触发,所以:
- 必须幂等:同一 prompt_ctx 下多次调用,结果应等价。
- 必须廉价:禁止在此发起昂贵 IO(网络、DB、文件 IO)。昂贵 IO 应在
_run/_async_run内 prefetch 到工具实例属性 /additional_info/ Neural 副信道,render 时只读。
异常降级¶
_format_2_prompt 抛异常会被框架捕获,本工具本轮的 prompt 被替换为 None + 记 ERROR 日志,其他工具仍正常注入,LLM 调用主链路不挂掉。
配置参数¶
| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
prompt_position |
ClassVar[tuple[ChatLLMPromptPosition \| None, DeskLLMPromptPosition \| None] \| None] |
Prompt 注入段位声明。未覆写 _format_2_prompt 时必须保持 None |
None |
| 方法 | 签名 | 返回类型 | 说明 |
|---|---|---|---|
_format_2_prompt |
(self, prompt_ctx: PromptContext) |
str \| BasePrompt \| None |
子类业务点。覆写时必须同时声明 prompt_position |
format_2_prompt |
(self, prompt_ctx: PromptContext) |
BasePrompt \| None |
父类框架方法,子类不应覆写 |
与运行时上下文注入的区别¶
工具有两条独立的「上下文」路径,职责不重叠:
| 维度 | Prompt 段位注入(本页) | 运行时上下文注入 |
|---|---|---|
| 目标 | 工具向 LLM 推送 prompt 内容 | 工具读取 / 修改 Chain 运行状态 |
| 触发时机 | 每次 LLM 调用前组装 prompt | 每次工具被 LLM 触发执行 |
| 关键字段 | prompt_position + _format_2_prompt |
merge_context + _run 收 **kwargs |
| 数据流向 | 工具 → LLM | Chain → 工具(双向:工具也可写 intermediate) |
| 是否每次 LLM 调用都跑 | 是 | 否(仅 LLM 决定调用本工具时) |
一个工具完全可以同时用这两条路径 —— 譬如某个知识库工具:
merge_context=True+_run内 prefetch 检索结果存到self._cached_hitsprompt_position=("system_msg_prompt", "system_prompt")+_format_2_prompt读self._cached_hits注入到 prompt
使用建议¶
- 不需要就别声明:默认
prompt_position = None是性能最优解,注入器对它有专门的短路。 - prefetch 放
_run、render 放_format_2_prompt:避免 prompt 组装阶段触发任何远程调用。 - i18n 工具回用 NormalizePrompt + FStrPromptTemplate:仓库已有现成的多 locale 模板能力,不要自己封装。
- 字面
{}别手动转义:返回 str 时框架替你转义;返回BasePrompt时你已经在用模板系统,按模板系统的规矩走。 - 校验错先看
__init_subclass__抛的 TypeError / ValueError 信息:那里会精确指出违反了哪一条契约。
与其他模块的协作¶
ChatLLM/DeskLLM(tfrobot/brain/chain/llms/):注入器宿主。ChatLLM 在 4 段位临时合并 prompt;DeskLLM 在 8 个 adapter 各自的_collect_dynamic_prompts处合并。PromptContext(tfrobot/schema/brain/chain/prompt/context_vars.py):_format_2_prompt的唯一入参,包含本轮 LLM 调用所需的全部上下文。BasePrompt/NormalizePrompt(tfrobot/brain/chain/prompt/):返回值类型。NormalizePrompt+FStrPromptTemplate是兜底实现,也是 i18n 推荐路径。- 设计 PRD:完整背景与架构权衡见
docs/proposals/base-tool-context-rendering.md。
如果工具需要的是「读取 Chain 当前的 intermediate / neural / current_tool_call_id」而不是「向 LLM 推 prompt」,请走另一条路径:工具运行时上下文注入。