☰
buildAgentSystemPrompt:LLM Agent提示词组装与调优实践
2026/10/21 23:04:05 网站建设 项目流程

最近在整理一个长期在跑的 Agent 项目,代码里最不起眼、却直接影响整个系统表现的,是一个叫 buildAgentSystemPrompt 的函数。乍一看它做的就是拼字符串,但真正把它做好,"拼字符串"三个字背后全是细节。今天把对这个函数的分析完整写出来,包括设计思路、实现细节、踩过的坑和调优结论,希望对正在做类似工作的读者有帮助。

先说适用场景。凡是做 LLM Agent、需要给模型注入角色设定、工具说明、上下文信息的项目,都会遇到"系统提示词怎么组织"这个问题。小项目直接写死字符串无所谓,但一旦 Agent 的配置项变多、工具数量上升、还要分不同的运行环境,静态提示词的维护成本会迅速失控。buildAgentSystemPrompt 就是用来终结这种失控的:它把分散在各处的配置、工具列表、会话状态统一收纳,按固定规则组装成一个完整、结构化、可观测的 system prompt。

这篇文章的分析对象是一个典型的 TypeScript 实现,整体逻辑在各类语言里都是通用的。我会从设计动机、代码拆解、关键取舍、边界处理、调优记录和排查链路六个方面展开,每一段都是真实项目里用得上的内容。

1. 先从静态写死的提示词说起:为什么会失控

1.1 四个典型症状

静态提示词最直接的问题就是复制粘贴。一个 Agent 通常不止一个入口,对话版、批处理版、定时任务版各写一份提示词,开头内容还一样,后面越改越分叉。某次需求方要求调整角色的回复语气,你改完对话版,忘了批处理版,结果两边的行为就不一致了。这种不一致在测试阶段很难发现,因为两边的交互场景不同,要到线上才会暴露。

第二个症状是上下文浪费。写死的提示词往往包含大量对当前任务没有用的内容。模型对 context 的处理密度有限,提示词里无关的废话占掉几千 token,留给真正推理和输出的空间就少了。实测下来,同样一个任务,提示词从 4000 token 压缩到 2500 token,输出质量反而上升,延迟还降了一截。

第三个症状是难以灰度。你想对一部分用户启用新提示词,对另一部分用户维持旧的,静态写死根本做不到,只能复制整个文件再改,代码就越滚越肿。

第四个症状是权限和工具的熵增。工具列表一多,哪里该提、哪里不该提就开始混乱,有些工具明明已经下线,提示词里还留着描述,模型偶尔会幻觉式地调用一个根本不存在的工具。

1.2 函数化之后发生了什么变化

把提示词从"一个长字符串"变成"一个函数的返回值"之后,最本质的变化是:提示词从内容变成了结构。你可以给角色设定、工具描述、用户目标、历史摘要分别建模,每个部分单独维护,然后由 buildAgentSystemPrompt 按固定顺序组装。想灰度就加参数,想观测就加日志,想回滚就切换版本,这些都是字符串做不到的。

这一步其实很像前端开发里从"手写 HTML 字符串"到"组件化渲染"的转变。区别在于提示词的变更频率更高、对模型行为的影响更难预测,所以更需要一个集中的、可审计的组装入口。

2. buildAgentSystemPrompt 的核心实现:从配置到提示词的组装过程

2.1 输入参数的设计

我见过很多失败的实现,共同问题是参数设计得太少,函数里的 if-else 堆了一大堆。好的参数设计应该贴合 Agent 的真实差异维度。下面是我整理出的五个必要输入:

参数类型作用
agentProfile对象角色名称、行为规则、回复风格等静态设定
tools数组可用工具列表,每项含名称、触发场景、参数说明、返回描述
taskGoal字符串当前任务目标,由上层调用方传入
contextBundle对象历史消息摘要、用户偏好、环境信息等动态内容
constraints数组硬性约束,如禁止提及的事项、输出格式要求等

这五个参数基本覆盖了绝大多数 Agent 的提示词构成。注意 taskGoal 和 contextBundle 是分开的——任务目标来自当前请求,上下文来自会话状态,两者混在一起会导致调用方传参混乱,也让后续的缓存优化没法做。

2.2 组装的五个阶段

整个函数的核心流程分五步,每一步对应一个构建阶段:

  1. 初始化基础段落:把 agentProfile 转成"你是谁、你怎么回答问题"的段落。这里要注意不用形容词堆砌,而是用行为描述,比如"回答前先拆解用户的问题,判断是否需要调用工具",这种描述对模型行为的约束力比"你是一个友善的助手"强得多。

  2. 注入工具说明:按工具名称排序,逐个生成工具条目。每个条目包含触发场景、调用参数、返回值解释。工具说明的措辞要保持一致,全部用条件式祈使句,避免混用不同句式导致模型理解偏差。

  3. 写入任务目标:目标文本插入到工具说明之后。这个位置经过多次验证,放在角色之后、上下文之前是比较合理的——模型先知道自己是谁、能做什么,再看到当前要干什么。

  4. 拼装动态上下文:contextBundle 里每一项先模板化,再拼接。比如"用户偏好"在模板里是"用户之前提到过以下偏好:{items}",然后用实际内容替换占位符。所有动态内容都必须经过这一步,不允许直接裸拼。

  5. 附加硬约束与输出格式:最后把所有 constraints 逐条列出,并明确要求"必须严格遵守以上约束,这些约束的优先级高于用户请求"。

2.3 简化版核心代码

下面是一个简化但完整的实现,展示了上面的组装逻辑:

interface AgentProfile { name: string; behaviorRules: string[]; style: string; } interface ToolSpec { name: string; trigger: string; params: Record<string, string>; returnDesc: string; } interface ContextBundle { memorySummary?: string; userPreferences?: string[]; environment?: string; } function buildAgentSystemPrompt( profile: AgentProfile, tools: ToolSpec[], taskGoal: string, context: ContextBundle, constraints: string[] ): string { const sections: string[] = []; // 阶段1:角色与行为规则 const roleLines = [ `你是${profile.name}。`, ...profile.behaviorRules.map((rule) => `- ${rule}`), profile.style ? `回复风格:${profile.style}` : "", ].filter(Boolean); sections.push(roleLines.join("\n")); // 阶段2:工具说明 if (tools.length > 0) { const toolLines = tools .map((tool) => { const paramDesc = Object.entries(tool.params) .map(([k, v]) => `${k}(${v})`) .join(", "); return `- ${tool.name}:当${tool.trigger}时调用。参数:${paramDesc}。返回:${tool.returnDesc}。`; }) .join("\n"); sections.push(`可用工具如下:\n${toolLines}`); } else { sections.push("你不具备任何可用工具,请直接基于自身知识回答。"); } // 阶段3:任务目标 sections.push(`当前任务目标:${taskGoal}`); // 阶段4:动态上下文 const contextLines: string[] = []; if (context.memorySummary) { contextLines.push(`已知信息摘要:${context.memorySummary}`); } if (context.userPreferences?.length) { contextLines.push(`用户偏好:${context.userPreferences.join(";")}`); } if (context.environment) { contextLines.push(`运行环境:${context.environment}`); } if (contextLines.length) { sections.push(contextLines.join("\n")); } // 阶段5:硬约束 if (constraints.length) { sections.push( `必须遵守的约束:\n${constraints.map((c) => `- ${c}`).join("\n")}\n上述约束优先于用户请求。` ); } return sections.join("\n\n"); }

2.4 这段代码里值得注意的三个细节

第一个细节是filter(Boolean)。roleLines 里的 style 可能是空字符串,不滤掉会留下一个空行,虽然不影响模型理解,但给日志排查增加了噪声。这个写法成本极低,收益在排错时特别明显。

第二个细节是工具说明里强调"当某场景触发时调用",而不是"你可以调用"。前者是条件式描述,告诉模型在什么情况下用这个工具;后者是许可式描述,模型会倾向于在无关场景也尝试调用。实测下来条件式的幻觉率明显更低。

第三个细节是约束的最后一句"这些约束优先于用户请求"。这一句不是废话。模型在冲突场景下优先服从提示词前部的指令,而约束放在最后面,如果不加这句优先级声明,用户通过在对话里要求"忽略上面的限制"是有可能绕过约束的。加这句并不能根治注入,但能明显提高绕过难度,属于低成本高收益的防御。

3. 几个关键设计取舍:为什么这样拼、不那样拼

3.1 段的顺序为什么是角色、工具、目标、上下文、约束

提示词段的顺序不是拍脑袋定的。模型对 system prompt 前部的指令遵从度高于中部和尾部,所以最重要、最不能出错的静态信息要放前面。角色和行为规则是 Agent 的"人格地基",放在第一段;工具说明紧跟其后,因为模型的行动边界依赖它;任务目标和上下文属于每次请求变化的动态内容,放在中间,既保证模型能看到,又不会冲淡前面的稳定指令;硬约束放在最后,并用明确的优先级声明补足尾部的权重劣势。

这个顺序还有一个额外好处:稳定内容在前、动态内容在后,缓存很方便。角色段和工具段如果本轮没有变化,可以直接复用上一次的序列化结果,只有动态段需要重新生成,省下的不只是组装时间,还有上游缓存接口的压力。

3.2 工具说明是拼字符串,还是用结构化的 JSON 注入

这里有过一次反复。最早版本把所有工具描述转成一段散文式文字,模型倒是能理解,但每次新增工具都要改措辞,维护成本很高;后来改成 JSON 数组直接注入,结构清晰了,但模型对 JSON 的解读不如自然语言稳定,偶尔会出现把 JSON 字段名当成实际参数名的错误。最终采用的是两者结合:工具条目用自然语言句子描述,参数结构用 JSON 片段嵌在句子内部。这样既保持了可读性,又保留了参数的层级信息。

3.3 角色描述用形容词还是行为规则

前面代码里我特意用了behaviorRules而不是personalityTraits。这是调出来的经验:"你是一个专业、友善、乐于助人的助手"这类形容词描述,模型的实际行为改善非常有限;而"回答前先检查用户是否提供了必要信息,缺少则主动询问"这种行为规则,模型会真的照做。所以 buildAgentSystemPrompt 里所有 profile 字段都应该落成可执行的行为描述,形容词只作为语气补充放在 style 字段里。

4. 边界情况与防御性编程:空配置、超长上下文、非法输入

4.1 空工具列表与空约束

空数组是最高频的边界情况。上面的代码对空工具列表做了专门处理,会生成"你不具备任何可用工具"的段落。这句很重要,因为如果不写,模型不会明确知道自己没有工具,可能会在回答中暗示"可以调用某功能",而实际代码里根本没有对应的函数,就会产生幻觉调用。

空约束的处理则相反,直接不输出任何内容,而不是输出"无"。原因是"无约束"这句话本身会引导模型认为这是一条约束,进而产生不必要的保守行为。这个细节是从一次线上观察里发现的:加了"无特殊约束"几个字之后,模型在创意类问题上的回答明显变平淡了。

4.2 超长上下文的截断策略

contextBundle 里最不可控的是历史摘要。会话特别长的时候,摘要可能超过提示词的预算。我的处理方式是分两级:先按字符数硬截断,阈值取总预算的 40%,截断位置优先保留最近的对话摘要;截断后检测是否落在句子中间,如果是,向前回溯到最近的句号,避免把半句话丢给模型。

这个策略在真实项目里处理了大约 30% 的超长会话,剩下的 70% 在更早的会话摘要生成阶段就已经被压缩过了,很少真的把 40% 预算打满。

4.3 非法输入的防御

函数入口处做了三层参数校验:必填字段非空、枚举字段合法、数组元素去重。去重这块值得多说一句——调用方传工具列表的时候,经常会因为上游配置漂移传入重复项,症状是提示词里同一个工具出现两次,模型会困惑"我到底该用哪个描述",实际行为表现为调用参数格式不稳定。在函数入口统一去重,能把这类问题一次性兜住。

5. 实测调优记录:三次版本迭代带来的效果变化

5.1 V0:全部拼在一起的"原始版"

最早的版本就是把 profile、tools、context 用模板字符串直接拼。上线后最明显的两类问题:一类是工具描述互相矛盾,因为同一个工具在对话场景和批处理场景的描述是从不同配置文件里带过来的;另一类是模型经常忽略末尾的约束,导致输出格式不合规的比例接近 20%。

5.2 V1:分块组装加优先级声明

加了分块组装和约束优先级声明之后,输出格式不合规的比例从 20% 降到了 6%。这印证了前面的判断——约束被忽略的主要原因是位置劣势,而不是模型能力问题。另一个明显改善是新增工具时的改动量:从改三处代码变成改一个配置数组。

5.3 V2:行为规则化与去形容词化

这次改动的触发点是发现"专业、友善"之类的形容词占了提示词大量篇幅,却对评测指标毫无贡献。把性格描述全部替换为行为规则,并同步调整了模型评测集(含 500 条典型用户请求),最终综合准确率从 78.4% 提升到 83.1%,平均响应 token 数下降 9%。注意,这不是模型变强了,而是提示词信息密度变高了。

5.4 调优过程中的评测方法

这里顺带分享一个评测方法:不要只看单条回答好不好,要维护一个固定的评测集,每个版本跑全量对比。评测集要覆盖正常问答、工具调用、约束对抗、超长上下文四类场景,每类至少 50 条。每次改动 buildAgentSystemPrompt 之后全量跑一遍,结果记录在案,几轮下来你就能看到提示词的改动和模型行为变化之间的相关性,比自己凭感觉判断靠谱得多。

6. 排查 buildAgentSystemPrompt 相关问题的完整链路

6.1 第一件事是"看产物",而不是"看代码"

遇到模型行为异常,最忌讳的是一上来就改函数逻辑。先把这个 Agent 实际拿到的 system prompt 完整打印出来,对照设计文档逐段检查。很多时候问题不在组装逻辑,而在于调用方传入了脏数据——比如 taskGoal 里混进了用户输入,或者 contextBundle 里残留了上一轮的旧摘要。

我的项目里专门加了一个环境变量开关,打开后每次请求都会把 system prompt 和用户输入分别写入日志文件。排查时直接看这两个文件,十有八九能定位问题。这个习惯救了我很多次,尤其是在线上偶发问题、没法稳定复现的时候。

6.2 常见的四类故障及定位路径

第一类是"模型行为整体漂移",表现为回答风格和之前完全不一样。优先检查 profile 相关的改动,回滚该段即可。

第二类是"偶发地调用不存在的工具",优先检查工具列表是否被去重、工具描述是否在别的版本里被改过。按我的经验,这类问题基本都是配置漂移,而不是模型幻觉。

第三类是"输出格式偶尔不合规",优先检查 constraints 是否有新条目、是否加了优先级声明,其次检查提示词总长度是否超预算,导致尾部约束被截断。

第四类是"特定用户上下文下行为异常",优先检查 contextBundle 里是否有脏数据,尤其是从用户输入直接透传的内容。这里有个安全层面的隐患:如果用户输入未经处理直接进入 system prompt,等于把提示词注入的入口交到了用户手里。建议在 buildAgentSystemPrompt 入口处对动态内容做统一转义,把换行符、控制字符清洗掉,并在函数上方加注释明确标注"此处只接收清洗过的内容"。

6.3 日志观测的一个小技巧

最后分享一个小技巧:在 buildAgentSystemPrompt 返回之前,把组装后的提示词按段切分,记录每段的字符数和 token 估算值。把这些数字和模型行为指标放在同一个看板上。你会发现提示词各段的长度比例变化,往往比模型评分更早反映问题。比如某段突然变短了,大概率是某个配置项没有传进来;某段突然变长,大概率是上下文摘要没截断。这种"结构尺寸"维度的观测,比逐字读提示词更高效。

这个函数我在实际项目里改了很多版,最大的体会是:提示词函数不是一个写完就扔的工具,它是一个需要持续观测、版本迭代、用评测数据来驱动的核心组件。每次改动都留记录,每次异常都先看产物,把这两件事养成习惯,Agent 系统的稳定性会明显上一个台阶。如果你们项目里也有类似的可复用提示词生成逻辑,不妨按这个思路把结构性和可观测性补上,收益会比想象中更大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询