三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

深入解析AI Agent系统提示词构建:模块化设计与工程实践

深入解析AI Agent系统提示词构建:模块化设计与工程实践

1. 项目概述:为什么System Prompt是Agent的“灵魂”?

如果你玩过AI Agent,尤其是像Hermes Agent这样的开源框架,你可能会花大量时间在模型选择、工具调用或者RAG(检索增强生成)上。但有一个环节,看似简单,却往往决定了Agent的“智商”上限和“性格”底色,那就是**System Prompt(系统提示词)**的组装。很多人把它当成一个简单的背景说明,随便写两句就完事了,结果就是Agent要么答非所问,要么像个没有感情的复读机。

在Hermes Agent的源码里,prompt_builder模块就是专门负责这个“灵魂”塑造的车间。它不是一个简单的字符串拼接,而是一个精密的组装流水线。为什么说它是灵魂?因为Agent本身没有“意识”,它所有的行为边界、思考逻辑、沟通风格,都源于你通过System Prompt赋予它的“初始设定”。一个优秀的System Prompt,能让一个通用的大模型瞬间化身为专业的客服、严谨的代码审查员或者富有创造力的编剧。而Hermes Agent的prompt_builder,则提供了一套标准化、模块化、可插拔的“灵魂”组装方案,让你能系统性地,而非凭感觉地,构建出强大且稳定的Agent。

简单来说,这个项目就是深入Hermes Agent的prompt_builder源码,拆解它如何将角色定义、能力约束、工具描述、对话历史等零散部件,组装成一个高效驱动Agent的完整“系统指令”。无论你是想深度定制自己的Agent,还是想学习大型AI项目中的提示工程最佳实践,这里面的设计思想都极具参考价值。

2. 核心设计思路:模块化与责任链

打开Hermes Agent的源码,找到prompt_builder相关的部分,你会发现它的设计非常清晰,核心思想就两点:模块化(Modularity)责任链模式(Chain of Responsibility)。这绝不是花架子,而是为了解决实际开发中的痛点。

2.1 模块化:告别“一坨”提示词

早期很多Agent项目,System Prompt就是一个巨大的、写死在代码里的字符串模板。想改个角色?得在一大段文本里找到对应位置小心翼翼地修改,极易出错。想为某个任务增加特殊约束?可能得重写整个提示词。这种“一坨式”的提示词,维护起来是噩梦。

Hermes Agent的prompt_builder将System Prompt分解为多个独立的、语义清晰的模块:

  • 角色定义模块:明确Agent是谁(例如,“你是一个资深的Python开发助手”)。
  • 核心指令模块:规定Agent的核心任务和行为规范(例如,“逐步思考”,“如果用户问题需要联网搜索,请先调用搜索工具”)。
  • 工具描述模块:动态注入Agent可以使用的工具(函数)的名称、描述和参数。这是让Agent“拥有手脚”的关键。
  • 知识库/上下文模块:注入RAG检索到的相关文档片段,为Agent提供外部知识。
  • 对话历史模块:管理多轮对话的上下文,让Agent拥有“记忆”。
  • 输出格式模块:严格规定Agent回复的结构,比如必须用JSON、必须包含某个字段,便于后端程序化解析。

每个模块相对独立,由一个专门的“构建器(Builder)”类或函数负责。这种设计的直接好处是可维护性和可复用性极强。你可以像搭积木一样,轻松替换、增加或移除某个模块,而不会影响其他部分。

2.2 责任链模式:有序的组装流水线

模块有了,怎么把它们按正确的顺序和逻辑组装起来?这里就用上了责任链模式。你可以想象一条流水线,每个工位(处理器)负责完成一项特定的组装任务,然后传递给下一个工位。

prompt_builder中,通常会有一个PromptBuilder主类,它维护着一个处理器(Handler)列表。当需要构建最终的Prompt字符串时,它会依次调用每个处理器的handle()方法。每个处理器只关心自己负责的那部分内容。例如:

  1. 角色处理器:首先添加“你是一个XXX”的指令。
  2. 指令处理器:接着添加核心行为准则。
  3. 工具处理器:检查当前会话是否启用了工具,如果有,则格式化所有工具的描述并添加。
  4. 上下文处理器:如果有检索到的知识,将其以“参考以下信息”的格式插入。
  5. 历史处理器:格式化之前的对话轮次,附加在最后。
  6. 格式处理器:最后,强调输出的格式要求。

这种链式处理的好处是灵活解耦。你想调整顺序?改一下处理器列表的顺序即可。你想增加一个处理用户元数据的新模块?只需新建一个处理器,插入到链中合适的位置,完全不需要改动其他处理器的代码。这为Agent能力的动态扩展提供了优雅的基础。

注意:在阅读源码时,你可能会看到类似build_system_prompt(task, tools, history, knowledge)这样的函数。它的内部实现,本质上就是在协调这条责任链的运转。理解这一点,你就抓住了prompt_builder的骨架。

3. 源码核心解析:从PromptBuilderSystemPrompt

让我们深入到一些关键代码片段,看看这些设计思想是如何落地的。请注意,以下代码是基于Hermes Agent典型设计的阐释和模拟,用于说明原理。

3.1 基础构建器类结构

通常,会有一个基础的抽象类或协议来定义构建器的行为。

# 模拟示意代码,展示责任链中的处理器概念 from abc import ABC, abstractmethod from typing import Dict, Any, List, Optional class PromptComponentHandler(ABC): """提示词组件处理器抽象基类。""" @abstractmethod def handle(self, context: Dict[str, Any]) -> str: """ 处理并返回自己负责的提示词组件字符串。 :param context: 构建上下文,包含任务、工具、历史等所有信息。 :return: 格式化后的提示词片段。 """ pass class RoleHandler(PromptComponentHandler): def handle(self, context: Dict[str, Any]) -> str: task = context.get('task', {}) role = task.get('role', 'AI助手') return f"你是一个{role}。\n" class InstructionHandler(PromptComponentHandler): def handle(self, context: Dict[str, Any]) -> str: # 这里可以读取预设的指令库,或者从task配置中获取 core_instructions = [ "请逐步思考,并在最终答案前简要说明你的推理过程。", "请严格使用提供的工具来获取信息或执行操作。", "如果用户的问题不清晰,请礼貌地请求澄清。", "你的回答应当准确、简洁、有帮助。" ] return "## 核心指令\n" + "\n".join([f"- {ins}" for ins in core_instructions]) + "\n\n"

3.2 工具描述的动态生成

这是prompt_builder最精彩的部分之一。Agent的工具(函数)列表可能是动态变化的。构建器需要将这些Python函数(通常用@tool装饰器标记)转化为模型能理解的、格式化的自然语言描述。OpenAI的Function Calling格式是一种常见标准。

# 模拟示意代码:工具处理器的核心逻辑 import inspect import json class ToolHandler(PromptComponentHandler): def handle(self, context: Dict[str, Any]) -> str: tools: List[Any] = context.get('tools', []) if not tools: return "" # 没有工具,则不生成任何文本 tool_descriptions = [] for tool in tools: # 获取工具函数对象 func = tool.func if hasattr(tool, 'func') else tool # 获取函数签名和文档字符串 sig = inspect.signature(func) doc = inspect.getdoc(func) or "无详细描述。" # 构建符合模型期望的工具定义(例如OpenAI格式) tool_def = { "type": "function", "function": { "name": func.__name__, "description": doc.split('\n')[0], # 取第一行作为简介 "parameters": self._generate_json_schema(sig, doc) # 一个将签名转为JSON Schema的方法 } } # 注意:实际传递给模型的可能是JSON字符串,但放入System Prompt时通常是格式化文本 tool_descriptions.append(f"- 函数名:`{tool_def['function']['name']}`\n 描述:{tool_def['function']['description']}") if tool_descriptions: return "## 可用工具\n你可以使用以下工具来帮助完成任务:\n" + "\n".join(tool_descriptions) + "\n\n**重要**:当你决定使用工具时,必须在你的回复中明确声明,并输出工具调用所需的精确参数。\n\n" return "" def _generate_json_schema(self, sig: inspect.Signature, doc: str) -> Dict: # 这是一个简化示例,实际实现会更复杂,需要解析docstring中的参数说明 schema = {"type": "object", "properties": {}, "required": []} for param_name, param in sig.parameters.items(): if param_name == 'self': continue # 简单映射Python类型到JSON Schema类型 param_type = str(param.annotation) if param.annotation != inspect.Parameter.empty else "string" schema["properties"][param_name] = {"type": param_type.lower().replace('int', 'integer').replace('bool', 'boolean')} if param.default == inspect.Parameter.empty: schema["required"].append(param_name) return schema

3.3 主构建器协调流程

最后,会有一个SystemPromptBuilder类来串联一切。

class SystemPromptBuilder: def __init__(self): # 初始化责任链,顺序很重要! self.handlers: List[PromptComponentHandler] = [ RoleHandler(), InstructionHandler(), ToolHandler(), # 可以插入KnowledgeHandler, HistoryHandler等 ] def build(self, task: Dict, tools: List, conversation_history: Optional[List]=None) -> str: """构建完整的System Prompt字符串。""" context = { 'task': task, 'tools': tools, 'history': conversation_history or [], } prompt_parts = [] for handler in self.handlers: part = handler.handle(context) if part: # 只添加非空部分 prompt_parts.append(part) # 添加一个不变的结尾指令,例如关于输出格式的强制要求 final_instruction = ( "\n## 最终输出要求\n" "请将你的最终答案放在『最终答案:』之后。\n" "如果使用了工具,请先输出工具调用过程和结果。\n" ) prompt_parts.append(final_instruction) # 将所有部分用两个换行符连接,确保可读性 full_system_prompt = "\n\n".join(prompt_parts) return full_system_prompt # 使用示例 builder = SystemPromptBuilder() task_config = {"role": "天气查询助手"} def get_weather(city: str) -> str: """根据城市名称查询天气信息。""" return f"{city}的天气是..." # 假设有一个Tool对象包装了get_weather weather_tool = Tool(func=get_weather) system_prompt = builder.build(task=task_config, tools=[weather_tool]) print(system_prompt)

运行上述模拟代码,你会得到一个结构清晰、要素完整的System Prompt。这比手动拼接字符串要可靠和强大得多。

4. 高级特性与实战技巧

理解了基本架构,我们再来看看prompt_builder中那些提升效率的高级特性和你必须知道的实战技巧。

4.1 上下文长度管理与优化

大模型有上下文窗口限制(如128K、200K)。System Prompt、对话历史、工具描述、检索的知识都在占用这个窗口。prompt_builder必须考虑长度管理

  1. 工具描述的智能裁剪:当工具很多时,完整的描述可能超长。高级的实现会有策略地压缩工具描述,比如只保留当前会话最可能用到的工具(通过任务类型预测),或者使用更简短的摘要。
  2. 对话历史的滑动窗口:不可能记住所有历史。prompt_builder通常会集成一个HistoryHandler,它只保留最近N轮对话,或者通过总结(Summarization)将长历史压缩成一段摘要。这在源码中可能体现为对conversation_history列表的截断或处理。
  3. 知识片段的优先级排序:从向量数据库检索出的知识片段可能有多个。构建器需要根据相关性分数,选择Top-K个最相关的片段插入,而不是全部塞进去。

实操心得:在自定义构建器时,一定要为每个可变的模块(历史、知识)设置max_tokens参数,并在拼接后估算总长度。一个简单的防护是在最终拼接前,如果发现总长度超过阈值(比如模型上限的70%),就触发一个裁剪策略,优先裁剪最旧的历史或相关性最低的知识片段。

4.2 条件化提示词注入

不是所有模块在所有场景下都需要。prompt_builder的另一个优势是支持条件化注入

  • 基于任务类型:如果任务配置task[‘type’]code_generation,则注入代码风格、安全规范的指令;如果是data_analysis,则注入严谨对待数据、说明数据来源的指令。
  • 基于用户身份:可以从上下文获取用户信息,如果是高级用户,提示词可以更简洁、技术性更强;如果是新手,则加入更多引导和解释性文字。
  • 基于会话阶段:首次对话和第十次对话,System Prompt可以微调。例如,在后续对话中,可以加入“请参考我们之前的对话历史”这样的指令。

这通常在各个Handlerhandle方法内部实现,通过判断context中的字段来决定是否生成内容以及生成什么内容。

4.3 模板引擎与外部化配置

硬编码提示词片段在大型项目中是不可接受的。成熟的prompt_builder会集成模板引擎(如Jinja2),并将提示词模板外部化(存放到YAML、JSON或数据库中)。

# prompts/system_roles.yaml coder: role: “你是一个经验丰富的全栈工程师,精通Python和JavaScript。” instructions: - “编写的代码必须包含清晰的注释。” - “优先考虑代码的可读性和可维护性。” - “解释你的解决方案时,请兼顾技术深度和新手友好度。” tutor: role: “你是一个耐心细致的编程导师。” instructions: - “用比喻和生活化的例子解释复杂概念。” - “多鼓励学习者,即使他们犯了错误。” - “通过提问引导学习者自己思考出答案。”

然后在RoleHandlerInstructionHandler中,不再是写死字符串,而是从配置文件中加载对应角色的模板进行渲染。这极大地提升了提示词的管理效率和A/B测试的便利性。

避坑指南:使用外部模板时,务必做好版本控制和回滚机制。一次失败的提示词修改可能导致所有Agent“行为失常”。建议每次更新后,先用一组标准问题集进行自动化测试,验证Agent的核心表现是否达标。

5. 常见问题与调试实战

在实际使用或借鉴Hermes Agent的prompt_builder时,你肯定会遇到各种问题。下面是一些典型场景和排查思路。

5.1 Agent“不听话”:忽略指令或错误使用工具

这是最常见的问题。首先,不要怀疑模型,先检查生成的System Prompt

  1. 打印并审查完整Prompt:在builder.build()方法返回后,将生成的system_prompt完整地打印出来。肉眼逐行检查:

    • 角色定义是否清晰无误?
    • 核心指令是否矛盾或过于模糊?(例如,“简洁回答”和“详细解释”同时存在)。
    • 工具描述是否准确?函数名、参数名、描述是否与后端实际注册的工具完全一致?一个字母的大小写错误都可能导致调用失败。
    • 输出格式要求是否明确?模型是否知道该以什么结构回应?
  2. 指令冲突与优先级:如果指令太多或相互冲突,模型可能会困惑。遵循“单一职责”原则,每条指令只规定一件事。对于关键指令(如“必须使用工具”),可以通过加重语气、重复强调、放在开头或结尾等位置来提升其权重。

  3. 工具描述的“幻觉”:如果工具描述说函数接受参数user_id,但实际代码中是uid,模型会基于描述生成错误的调用参数。确保工具描述(通常是函数的docstring)与函数签名严格同步。可以考虑使用pydantic模型来自动生成JSON Schema和描述,从根源上保证一致性。

5.2 性能瓶颈:Prompt构建耗时过长

当工具很多、历史很长、知识片段很复杂时,构建Prompt可能成为API调用的前序瓶颈。

  • 异步化处理:如果各个Handler的处理逻辑相对独立(如获取知识片段需要调用向量数据库),可以考虑将它们改造为异步方法,并使用asyncio.gather并发执行。
  • 缓存策略:对于不常变化的部分,如角色定义、固定指令、工具描述(在工具集不变的情况下),可以将其构建结果缓存起来,下次直接复用,而不是每次都重新格式化和拼接。
  • 懒加载与预计算:在应用启动时,预计算所有可能的静态模块组合。运行时只需要动态拼接变化的部分(如当前历史、当前知识)。

5.3 与不同模型的兼容性问题

不同的LLM对System Prompt的格式、长度、指令风格的敏感度不同。

  • Claude系列:对XML标签(如<instruction>)响应良好,喜欢结构非常清晰、分点列出的提示词。
  • GPT系列:对自然语言段落和##标题的Markdown风格接受度很高。
  • 国产大模型:可能需要更直接、更简短的指令,有时对复杂嵌套的格式解析不如国际模型稳定。

解决方案:在PromptBuilder中引入适配器模式。定义BaseModelAdapter,然后为GPTAdapterClaudeAdapterDeepSeekAdapter等创建子类。每个适配器知道如何将通用的内部提示词模块表示,转化为最适合目标模型的格式。这样,核心的业务逻辑(组装哪些模块)不变,只需切换适配器就能适配不同后端模型。

class BaseModelAdapter(ABC): @abstractmethod def format_role(self, role: str) -> str: ... @abstractmethod def format_tools(self, tools: List) -> str: ... class GPTAdapter(BaseModelAdapter): def format_role(self, role: str) -> str: return f"你扮演{role}。\n\n" def format_tools(self, tools: List) -> str: # 格式化成OpenAI的function calling描述文本 ... class ClaudeAdapter(BaseModelAdapter): def format_role(self, role: str) -> str: return f"<role>{role}</role>\n" def format_tools(self, tools: List) -> str: # 格式化成Claude的XML工具描述风格 ...

5.4 调试记录表

当你遇到问题时,可以按以下清单快速排查:

问题现象可能原因检查点与解决方案
Agent完全无视工具1. 工具描述未成功注入Prompt。
2. 模型未正确识别工具描述格式。
3. System Prompt过长,工具描述被“挤”到后面,模型未充分注意。
1. 打印完整Prompt,确认工具描述段落存在且格式正确。
2. 对比官方文档,检查工具描述格式是否符合特定模型要求。
3. 尝试精简Prompt其他部分,或将工具描述移到更靠前的位置。
Agent调用工具参数错误1. 工具描述中的参数名/类型与后端不匹配。
2. 模型对参数理解有歧义。
1. 仔细比对prompt_builder生成的描述与实际函数签名和docstring。
2. 在工具描述中为每个参数添加更具体、无歧义的例子(如city: str (例如:“北京”, “上海”))。
Agent回复风格不符合预期1. 角色定义不清晰或矛盾。
2. 核心指令被后续内容“淹没”。
3. 模型本身风格与指令不匹配。
1. 强化角色定义,使用更具体、生动的描述(如“你是一个说话像《生活大爆炸》里谢尔顿的科学家”)。
2. 将最关键的行为指令(如“用中文回答”)放在Prompt的最开始或最后,并单独成行。
3. 尝试不同的指令措辞,或更换更擅长遵循指令的模型(如Claude-3)。
长对话后Agent失忆或混乱1. 对话历史处理不当,未正确截断或总结。
2. 历史与当前问题在Prompt中格式混乱。
1. 检查HistoryHandler的截断逻辑(如只保留最近5轮)。
2. 为对话历史设计清晰的标记格式(如用户:助手:),并与系统指令部分用明显的分隔符(如---)分开。

6. 从理解到定制:构建你自己的Prompt流水线

读懂了Hermes Agent的设计,你就可以将其思想应用到自己的项目中,甚至构建更强大的版本。

第一步:定义你的核心模块列出你的Agent必须的所有信息部件。至少包括:角色、指令、工具、历史。还可以考虑:安全策略、回复模板、思维链(CoT)触发词、外部知识等。

第二步:设计处理器接口像上面一样,定义一个Handler基类。确保每个处理器只做一件事,并且通过context字典获取所需数据。

第三步:实现可插拔的构建器主构建器类维护一个处理器列表。提供add_handlerremove_handlerinsert_handler等方法,允许在运行时动态调整流水线。这对于实现可配置的Agent技能非常有用。例如,你可以为“联网搜索”技能准备一个WebSearchHandler,当用户启用该技能时,就将其加入处理链。

第四步:集成配置与模板将角色、指令等文本内容剥离到配置文件中。使用YAML或JSON管理。对于复杂的动态内容,集成Jinja2模板引擎,允许在模板中使用变量和简单逻辑。

第五步:添加监控与测试钩子build方法中,加入日志记录,记录最终生成的Prompt长度、包含的模块等。编写单元测试,模拟不同的输入(空工具、长历史等),确保生成的Prompt格式正确、长度可控。

一个进阶思路是引入Prompt版本管理。每次对Prompt模板或构建逻辑的修改,都生成一个版本号。在日志和数据库中记录每个会话使用的Prompt版本。当发现某个版本的Agent行为出现集体偏差时,可以快速定位和回滚。

最后,记住System Prompt工程是半经验性的。再好的架构也需要基于实际效果进行迭代和调优。prompt_builder的价值在于,它将这个迭代过程从混乱的字符串操作,变成了结构清晰、可测试、可复用的软件工程过程。它让Agent的“灵魂”塑造,从一门玄学,更靠近一门工程学科。

← 返回列表