Published on

Agent Harness 工程:提示词的运行时组装

Authors
  • avatar
    Name
    XiaoLeiJun
    Twitter

Agent Harness 工程:提示词的运行时组装

前面几篇里,我们的 SYSTEM_PROMPT 基本都是固定的。

在最开始,这样做没有问题。那时 Harness 只有一个很小的 Agent Loop:模型看到用户输入,决定是否调用工具,工具结果再回到 messages,循环继续。

但现在已经不一样了。

我们陆续加入了:

  • 工具注册表;
  • 权限与审批;
  • Hook;
  • TodoWrite;
  • Subagent;
  • Skill 按需加载;
  • 上下文压缩;
  • Memory Store。

这些能力都需要提示词配合。工具需要调用规范,权限需要边界说明,Todo 需要计划规则,Skill 需要加载策略,记忆需要注入方式,上下文摘要也需要告诉模型如何使用。

如果继续把所有规则都写在一个固定的 SYSTEM_PROMPT 里,它很快会变成一块越来越大的文本。

这篇就来解决这个问题:提示词不应该只是一个固定字符串,而应该是 Harness 在每轮模型调用前,根据当前运行态组装出来的输入。

固定提示词的问题

固定提示词在早期很舒服,因为它简单:

SYSTEM_PROMPT = """
You are a helpful coding agent.
You can call tools.
Follow the user's instructions.
"""

每次调用模型时,把它塞进第一条 system message 就行。

但随着能力变多,问题会越来越明显。

第一,换项目时很难改。

有些提示词是 Agent 的通用身份,有些是当前项目的约束,有些是工具协议,有些是用户偏好。它们如果混在一起,换一个项目时就不知道哪些应该保留、哪些应该删除、哪些应该改写。

最后通常会变成“复制上一份提示词,然后凭感觉改几段”。这对小 demo 可以,对长期维护的 Harness 不太行。

第二,局部修改会影响全局行为。

比如你只想改 TodoWrite 的使用规则,但这段规则和工具调用规则、权限规则、任务规划规则挤在同一个大提示词里。模型看到的是整体文本,不会天然知道哪一段只适用于 Todo。

一处改动可能让另一个能力的行为也发生变化。

第三,每次调用都会携带无关内容。

当前任务只是问“解释一下这个函数”,却把 Subagent 委派规范、Skill 加载细节、长期记忆策略、部署权限说明全部发给模型,这就是浪费上下文窗口和 token。

更糟的是,无关规则也可能干扰模型判断。

第四,很难利用 prompt cache。

很多模型服务都会对稳定前缀做缓存或优化。即使不同服务暴露的接口不一样,一个经验也基本成立:越稳定、越靠前、越少变化的提示词部分,越容易被复用。

如果你把动态信息混进固定提示词,比如当前时间、当前 Todo、刚检索到的记忆、刚压缩出来的摘要,那么每轮 system prompt 都不一样,稳定前缀就被破坏了。

所以,提示词需要从“一个常量”变成“一个运行时组装过程”。

先把提示词拆成 Section

解决办法不是把 SYSTEM_PROMPT 写得更长,而是先把它拆成多个有边界的 Section。

一个 Section 负责一个明确主题:

Section负责什么是否通常稳定
core_identityAgent 身份、总目标、协作风格稳定
tool_protocol工具调用格式、工具结果如何处理稳定
available_tools本轮实际启用的工具清单动态
permission_policy权限分级、哪些动作需要审批稳定或半稳定
project_context当前仓库、命令、目录约束动态
todo_state当前计划、进行中步骤动态
loaded_skills本轮已经加载的 Skill 内容动态
relevant_memories从 Memory Store 检索到的相关记忆动态
context_summary压缩后的历史摘要动态

拆开之后,每个 Section 都可以有自己的加载策略:

  • 有些每次都加载;
  • 有些只有启用某个能力时加载;
  • 有些只有存在对应状态时加载;
  • 有些只在当前任务相关时加载;
  • 有些可以稳定缓存;
  • 有些必须每轮重新渲染。

可以把提示词组装理解成一次小型编译:输入是当前运行态,输出是本轮模型调用要看到的 system prompt。

提示词运行时组装流程

Prompt Assembler 不靠关键词猜测任务,而是读取 Harness 的真实运行态:工具是否启用、Todo 是否存在、Skill 是否加载、记忆是否检索到、上下文摘要是否可用。

context 不是关键词猜测

这里有一个很容易做错的点:不要把动态组装做成关键词匹配。

比如:

if "test" in user_message:
    include_testing_prompt()

这看起来很轻巧,但问题很多。

用户说“不要运行 test”,也会命中;用户说“这个变量名叫 latestResult”,也可能被误判;用户没有说关键词,但当前 Todo 已经进入测试阶段,反而加载不到测试规则。

更合理的做法是让 PromptContext 反映 Harness 的真实状态:

  • 当前启用了哪些工具;
  • 权限模式是什么;
  • Todo 列表里是否有进行中的步骤;
  • 是否已经加载了某个 Skill;
  • Memory Store 是否检索到了相关记忆;
  • 上下文压缩层是否生成了摘要;
  • 当前项目的工作目录和已知约束是什么;
  • 当前任务类型是否由上游分类器、工具状态或显式计划确认。

也就是说,context 应该来自运行态,而不是来自对用户输入的模糊猜测。

先写一个最小 Prompt Assembler

不要一开始就把 Prompt Assembler 做得太复杂。

教学版先抓住三件事就够了:

  1. 当前运行态是什么;
  2. 哪些提示词片段应该进入本轮调用;
  3. 这些片段按什么顺序拼起来。

先定义一个最小的 PromptContext

from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Literal


SectionStrategy = Literal["always", "when_non_empty"]


@dataclass
class PromptContext:
    workdir: Path
    enabled_tools: list[str]
    permission_mode: str
    active_todos: list[dict[str, str]] = field(default_factory=list)
    loaded_skill_names: list[str] = field(default_factory=list)
    relevant_memories: list[str] = field(default_factory=list)
    context_summary: str = ""
    project_notes: list[str] = field(default_factory=list)

它不是给模型看的文本,而是 Harness 内部的状态快照。

然后定义一个 Section。这里先只保留最必要的字段:

@dataclass
class PromptSection:
    name: str
    priority: int
    cacheable: bool
    strategy: SectionStrategy
    render: Callable[[PromptContext], str]

几个字段的意思很直接:

字段作用
nameSection 名称,方便调试和日志记录
priority拼接顺序,越小越靠前
cacheable是否适合放进稳定前缀
strategyalwayswhen_non_empty
render根据 PromptContext 渲染文本

接下来先写几个 renderer。

注意这里的写法故意很朴素:每个 renderer 只负责把一种状态渲染成一小段文本。

def render_core(ctx: PromptContext) -> str:
    return """
You are an engineering agent running inside an Agent Harness.
Use tools when needed, respect permission boundaries, and keep task state aligned.
""".strip()


def render_runtime_state(ctx: PromptContext) -> str:
    tools = ", ".join(sorted(ctx.enabled_tools)) or "none"
    notes = "\n".join(f"- {note}" for note in ctx.project_notes) or "- No project notes."

    return f"""
Current working directory: {ctx.workdir}
Permission mode: {ctx.permission_mode}
Enabled tools: {tools}

Project notes:
{notes}
""".strip()


def render_todos(ctx: PromptContext) -> str:
    if not ctx.active_todos:
        return ""

    lines = [
        f"- [{todo.get('status', 'pending')}] {todo.get('content', '')}"
        for todo in ctx.active_todos
    ]
    return "Current Todo state:\n" + "\n".join(lines)


def render_memories(ctx: PromptContext) -> str:
    if not ctx.relevant_memories:
        return ""

    lines = [f"- {memory}" for memory in ctx.relevant_memories]
    return "Relevant memories:\n" + "\n".join(lines)


def render_context_summary(ctx: PromptContext) -> str:
    if not ctx.context_summary.strip():
        return ""

    return "Compressed history summary:\n" + ctx.context_summary.strip()

然后注册 Section:

PROMPT_SECTIONS = [
    PromptSection(
        name="core",
        priority=10,
        cacheable=True,
        strategy="always",
        render=render_core,
    ),
    PromptSection(
        name="runtime_state",
        priority=100,
        cacheable=False,
        strategy="always",
        render=render_runtime_state,
    ),
    PromptSection(
        name="todos",
        priority=110,
        cacheable=False,
        strategy="when_non_empty",
        render=render_todos,
    ),
    PromptSection(
        name="memories",
        priority=120,
        cacheable=False,
        strategy="when_non_empty",
        render=render_memories,
    ),
    PromptSection(
        name="context_summary",
        priority=130,
        cacheable=False,
        strategy="when_non_empty",
        render=render_context_summary,
    ),
]

最后组装:

def assemble_system_prompt(ctx: PromptContext) -> str:
    blocks = []

    for section in sorted(PROMPT_SECTIONS, key=lambda item: item.priority):
        text = section.render(ctx).strip()

        if section.strategy == "when_non_empty" and not text:
            continue

        blocks.append(f"## {section.name}\n{text}")

    return "\n\n".join(blocks)

这个版本已经能解决大部分问题:固定内容和动态内容分开,空 Section 不会进入本轮提示词,顺序也稳定。

后续如果需要更细,可以再加 when_enabledwhen_relevantenabled_by、token 预算、Section fingerprint。但这些都属于工程增强,不是理解这篇文章的前置条件。

Skill 怎么接进来

上面这个最小版本里还没有展开 Skill,因为第 8 篇已经讲过 Skill 的按需加载。

在真实 Harness 里,Skill 更适合接在 Prompt Assembler 前面:

  1. Skill Loader 根据任务和 Skill 目录判断应该加载哪些 Skill;
  2. 加载后的 Skill 内容进入 PromptContext
  3. Prompt Assembler 把“已经加载的 Skill”渲染成一个动态 Section。

也就是说,Prompt Assembler 不负责“猜测应该加载哪个 Skill”,它只负责消费已经确认的运行态。

这和前面的原则一致:context 反映当前运行态,不是关键词猜测。

如果要加到代码里,可以只补一个字段和一个 renderer:

def render_loaded_skills(ctx: PromptContext) -> str:
    if not ctx.loaded_skill_names:
        return ""

    skills = "\n".join(f"- {name}" for name in ctx.loaded_skill_names)
    return "Loaded skills:\n" + skills

这样读者会更容易抓住主线:先有运行态,再按运行态组装提示词。

放进模型调用流程

现在把它接回 Agent Loop。

前几篇里的调用流程大概是:

response = client.chat.completions.create(
    model="...",
    messages=[
        {"role": "system", "content": SYSTEM_PROMPT},
        *messages,
    ],
    tools=tool_schemas,
)

现在改成:

def build_model_messages(
    user_message: str,
    messages: list[dict],
    state: HarnessState,
) -> list[dict]:
    ctx = PromptContext(
        workdir=state.workdir,
        enabled_tools=list(state.tool_registry.names()),
        permission_mode=state.permission_mode,
        active_todos=state.todo_store.active_items(),
        loaded_skill_names=state.skill_loader.loaded_names(),
        relevant_memories=[
            item.content
            for item in state.memory_store.search(user_message, limit=5)
        ],
        context_summary=state.context_manager.summary,
        project_notes=state.project_notes,
    )

    system_prompt = assemble_system_prompt(ctx)

    return [
        {"role": "system", "content": system_prompt},
        *state.context_manager.compact(messages),
    ]

然后调用模型:

model_messages = build_model_messages(
    user_message=user_message,
    messages=messages,
    state=state,
)

response = client.chat.completions.create(
    model=state.model,
    messages=model_messages,
    tools=state.tool_registry.schemas(),
)

这样,Prompt Assembler 就成为模型调用前的一层明确步骤。

它不替代上下文压缩,也不替代记忆检索。它做的是最后一步:把这些运行态信息整理成模型可读的提示词。

让稳定前缀适合缓存

前面提到 prompt cache,这里展开一点。

Prompt cache 的具体机制取决于模型服务。不同服务可能叫法不同,缓存粒度也不同。但对 Harness 设计来说,有一条朴素原则很有价值:

把稳定内容放在前面,并尽量保持字节级稳定;把动态内容放在后面。

可以把 system prompt 分成两段:

Prompt Cache 布局

稳定前缀放身份、工具协议和权限规则;动态后缀放当前项目、Todo、记忆、Skill 和上下文摘要。不要把时间戳、当前任务状态、检索结果混进稳定前缀。

为了做到这一点,先记住三条就够了。

第一,稳定 Section 不要包含动态值。

比如工作目录、当前 Todo、当前时间、检索出来的记忆,都不应该放进 cacheable=True 的 Section。

第二,稳定 Section 的顺序不要随便变。

如果每次注册顺序都不同,即使文本内容差不多,也会降低缓存命中概率。

所以 priority 最好是固定数字,而不是依赖字典遍历顺序。

第三,动态 Section 尽量短。

记忆、摘要、Todo 都要有预算,不要因为它们是“按需加载”就无限加入。

真实系统里可以给每类动态 Section 设置预算,比如最多注入 5 条记忆、最多保留 3000 字符摘要。更精细的裁剪可以交给上下文压缩层处理。

如果后面要排查缓存问题,再给稳定前缀计算 fingerprint 也不迟。教学版先不把这件事放进主流程。

Section 之间不要互相打架

提示词拆成 Section 之后,还会出现一个新问题:多个 Section 可能写出冲突规则。

比如:

  • tool_protocol 说“工具失败后可以重试”;
  • permission_policy 说“被拒绝的操作不能绕过审批”;
  • skill 里又说“失败后继续尝试替代命令”。

这三个规则单独看都合理,放在一起就可能让模型困惑。

所以 Section 设计要有层级。

一个简单优先级可以是:

  1. 安全和权限规则优先;
  2. 用户明确要求优先;
  3. 当前任务状态优先于长期记忆;
  4. 最近工具观察优先于旧摘要;
  5. Skill 只能补充领域流程,不能覆盖 Harness 的基础边界。

这段可以放进稳定核心提示词里,而不是在每个 Section 里重复一遍。

不要让 Prompt Assembler 变成另一个 Agent

Prompt Assembler 应该是确定性的。

它可以读取状态、选择 Section、排序、裁剪、拼接,但不应该自己开始推理:“我感觉这次应该多塞一点某某规则。”

如果你需要更复杂的判断,最好把判断结果显式写进状态里。

比如:

  • Skill Loader 判断应该加载哪个 Skill;
  • Memory Retriever 判断哪些记忆相关;
  • Context Manager 判断摘要和近期消息怎么取舍;
  • Planner 或 TodoWrite 维护当前步骤;
  • Permission Manager 判断动作是否允许。

Prompt Assembler 只消费这些结果。

这样系统会更容易调试。否则当模型表现异常时,你会不知道问题出在 Skill 检索、记忆检索、上下文压缩,还是提示词组装器自己的隐式判断。

常见坑

第一,把 Section 拆了,但仍然每次全量加载。 这只是把一个大提示词拆成多个小文件,问题没有解决。Section 还必须有加载策略。

第二,用关键词决定一切。 关键词可以作为弱信号,但不能替代运行态。Todo、Skill、Memory、Tool Registry、Permission Mode 才是更可靠的依据。

第三,把动态信息放进稳定前缀。 当前时间、当前任务、当前目录、检索记忆、上下文摘要都不应该混进 cacheable Section。

第四,Section 之间重复规则。 重复会让提示词变长,也会制造冲突。通用规则放稳定 Section,能力细节放对应 Section。

第五,过度裁剪动态状态。 按需加载不等于少到看不懂。比如当前 Todo 和关键记忆如果被裁掉,模型会失去任务连续性。

第六,完全不记录组装结果。 至少在调试日志里记录本轮加载了哪些 Section。这样模型表现异常时,你不用盯着一整段 system prompt 猜原因。

小结

这一篇把固定 SYSTEM_PROMPT 拆成了运行时组装机制:

  1. 固定提示词会随着能力增加变得难维护、难复用、难缓存;
  2. 提示词应该拆成多个有边界的 PromptSection
  3. 最小实现只需要 alwayswhen_non_empty 两种加载策略;
  4. PromptContext 应该反映 Harness 的真实运行态,而不是关键词猜测;
  5. 稳定 Section 放前面,动态 Section 放后面,以便更好利用 prompt cache;
  6. Prompt Assembler 只负责确定性拼接,不要变成另一个会隐式推理的 Agent;
  7. 后续再按需要补充 feature flag、预算裁剪、fingerprint 和更细的调试信息。

到这里,Agent Harness 的模型输入已经不再是一段固定文本,而是由工具、权限、Todo、Skill、上下文摘要和记忆共同决定的运行时产物。

下一章讲 错误恢复。Agent 运行时可能因为工具失败、权限拒绝、上下文超限、网络中断、模型输出格式错误等原因失败。它们不是偶发 bug,而是 Agent 系统的常态;真正重要的是 Harness 如何识别失败、保留现场、选择重试策略,并把任务从失败状态里拉回来。