Skip to content

工具 Prompt 段位注入

「LLM 怎么知道这个工具该怎么用?」—— 大部分情况下,descriptionparams_schema 就够。但有些工具是「有状态、需要给 LLM 加背景」的:知识库召回工具想告诉 LLM「当前命中了哪些片段」,记忆工具想插入「用户的长期偏好」,IDE 工具想告诉 LLM「当前打开的文件是 X」。这些信息不属于「调用结果」,而是「LLM 推理前需要看到的上下文」。

本页讲清楚工具如何用 prompt_position + _format_2_prompt() 把自己的 Prompt 插入到 LLM 消息流的指定段位 —— 即 PRD docs/proposals/base-tool-context-rendering.md 在用户侧的落地说明。

设计理念

工具 Prompt 注入面临三个矛盾:

  1. 不能改用户意图:工具的封装 bug 不能污染用户原话或任务目的,否则 LLM 决策依据被悄悄改写、bug 现场不可溯源。
  2. 不能改历史快照conversation_msgsintermediate_msgs 是消息流,承载因果性,不属于「工具可配置 prompt 位」。
  3. 不能强制:30+ 已有工具不可能为了「Prompt 注入能力」一夜全部覆写空方法。

解法是「覆写即强制思考 + 显式选择」:

  • 默认不注入。工具不写 _format_2_prompt → 完全零侵入。
  • 一旦覆写 _format_2_prompt,必须同时声明 prompt_position 元组((ChatPos | None, DeskPos | None)),明确告诉框架「我要插哪个段位」。
  • prompt_position 取值范围被 Literal 锁死,违反铁律的段位(如 DeskLLM 的 purpose_promptoriginal_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)做五件事:

  1. 未覆写 _format_2_prompt 但声明了 prompt_positionTypeError(多余声明)
  2. 覆写了 _format_2_prompt 但未声明 prompt_positionTypeError(覆写即强制思考)
  3. prompt_position = (None, None)ValueError(与 None 重复)
  4. chat_pos 不在 _VALID_CHAT_POSITIONSValueError(含铁律 1 拒绝位)
  5. desk_pos 不在 _VALID_DESK_POSITIONSValueError(含铁律 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_hits
  • prompt_position=("system_msg_prompt", "system_prompt") + _format_2_promptself._cached_hits 注入到 prompt

使用建议

  • 不需要就别声明:默认 prompt_position = None 是性能最优解,注入器对它有专门的短路。
  • prefetch 放 _run、render 放 _format_2_prompt:避免 prompt 组装阶段触发任何远程调用。
  • i18n 工具回用 NormalizePrompt + FStrPromptTemplate:仓库已有现成的多 locale 模板能力,不要自己封装。
  • 字面 { } 别手动转义:返回 str 时框架替你转义;返回 BasePrompt 时你已经在用模板系统,按模板系统的规矩走。
  • 校验错先看 __init_subclass__ 抛的 TypeError / ValueError 信息:那里会精确指出违反了哪一条契约。

与其他模块的协作

  • ChatLLM / DeskLLMtfrobot/brain/chain/llms/:注入器宿主。ChatLLM 在 4 段位临时合并 prompt;DeskLLM 在 8 个 adapter 各自的 _collect_dynamic_prompts 处合并。
  • PromptContexttfrobot/schema/brain/chain/prompt/context_vars.py_format_2_prompt 的唯一入参,包含本轮 LLM 调用所需的全部上下文。
  • BasePrompt / NormalizePrompttfrobot/brain/chain/prompt/:返回值类型。NormalizePrompt + FStrPromptTemplate 是兜底实现,也是 i18n 推荐路径。
  • 设计 PRD:完整背景与架构权衡见 docs/proposals/base-tool-context-rendering.md

如果工具需要的是「读取 Chain 当前的 intermediate / neural / current_tool_call_id」而不是「向 LLM 推 prompt」,请走另一条路径:工具运行时上下文注入