Prompt Engineering:让LLM听懂你的话 — 从硬编码到模板化到多段式
先说结论
Prompt 是 Agent 的"操作系统"。
不是夸张 — Agent 的每一次决策、执行、校验,都靠 Prompt 告诉 LLM "你是谁、做什么、怎么做、不能做什么"。Prompt 写得好,Agent 就聪明;Prompt 写得乱,Agent 就胡说八道。
工程化的第一步:模板化配置 + 变量注入。把 Prompt 从代码里抽出来,变成可配置、可版本管理的模板文件。
一、System Prompt vs User Prompt:角色分工
LLM 的每次调用都有两个 Prompt 槽位,分工明确:
System Prompt:告诉 LLM "你是谁"
定义角色身份、专业领域、行为边界。LLM 会以此为"世界观"来约束自己的输出。
你是一位美妆赛道的干货科普专家,风格亲和口语化。
这一句话做了三件事:
角色:干货科普专家(不是销售、不是评测博主)
领域:美妆赛道(不会聊投资、不会聊健身)
风格:亲和口语化(不会写论文腔)
User Prompt:告诉 LLM "做什么"
具体的任务指令 + 上下文数据。每次调用都不同。
标题:夏季防晒的5个误区 选题方向:夏季防晒推荐 风格约束:语气亲和,emoji中频 请创作正文内容。
为什么分两层?
| System Prompt | User Prompt | |
|---|---|---|
| 角色 | 定义身份 | 下达任务 |
| 变化频率 | 同一人设不变 | 每次调用都变 |
| 存储位置 | 模板文件 | 代码动态拼接 |
| 类比 | 操作系统 | 应用程序 |
关键原则:System Prompt 越稳定,LLM 的行为越一致。User Prompt 越具体,LLM 的输出越精准。
二、Prompt 的模板化:从硬编码到配置文件
反面教材:Prompt 写死在代码里
# ❌ 硬编码:改一个词要改代码、发版 result = await llm.generate( system_prompt="你是一位美妆赛道的干货科普专家,风格亲和口语化。", user_prompt=f"请写一篇关于{topic}的文章", )问题:
想改"亲和口语化"为"犀利直白" → 改代码 → 提交 → 发版
想加一条禁忌"不要用'众所周知'" → 改代码 → 提交 → 发版
想给不同赛道用不同 Prompt → if/else 堆积 → 代码膨胀
正面教材:模板文件 + 变量注入
项目中的prompts/body_generation.md:
你是一位{{ niche }}赛道的{{ persona_type }},风格{{ writing_style }}。 # 任务 基于选题和标题,创作一篇{{ content_format }}正文。 # 结构要求 1. 开篇:抓住眼球,3秒内留住读者 2. 核心干货:分3-5段,每段一个小标题+干货内容 3. 总结收尾:简洁总结+轻引导互动 # 风格约束 - 语气:{{ writing_style }} - emoji频率:{{ emoji_frequency }} - 严格遵循禁忌规则 - 不要使用AI生硬书面语,保持自媒体口语化表达{{ niche }}、{{ writing_style }}是 Jinja2 占位符,运行时由代码注入真实值。
模板化的好处:
改 Prompt 不改代码,直接编辑
.md文件不同人设自动适配:美妆人设注入
niche=美妆,职场人设注入niche=职场Prompt 可以版本管理、Code Review、A/B 测试
模板引擎:PromptManager
项目用 Jinja2 做 Prompt 模板渲染,核心代码llm/prompt.py:
class PromptManager: def __init__(self, prompts_dir: str = "prompts"): self.env = Environment( loader=FileSystemLoader(str(self.prompts_dir)), keep_trailing_newline=True, ) def get_template(self, name: str) -> str: """获取原始模板(不渲染)""" template = self.env.get_template(f"{name}.md") return template.render() def render(self, name: str, **kwargs: str) -> str: """渲染模板:占位符 → 真实值""" template = self.env.get_template(f"{name}.md") return template.render(**kwargs)调用方式:
# 获取 System Prompt(模板已渲染占位符) system_prompt = prompt_mgr.get_template("body_generation") # 或者动态渲染 rendered = prompt_mgr.render("body_generation", niche="美妆", writing_style="亲和口语化")三、Prompt 的多段式:像搭积木一样组装
一个好的 Prompt 不是一段文字,而是多段组装,每段有明确职责:
角色设定 + 任务描述 + 约束条件 + 输出格式
项目中的实战:正文生成的完整 Prompt 拼装
content/body.py中的generate_text()方法,展示了 User Prompt 如何多段组装:
user_prompt = ( # ━━ 第1段:上下文信息 ━━ f"赛道:{persona.niche}\n" f"人设类型:{persona.persona_type}\n" f"文案风格:{persona.writing_style}\n" f"内容形式:{persona.content_format}\n\n" # ━━ 第2段:任务数据 ━━ f"标题:{title}\n" f"选题方向:{topic.title}\n" f"选题分类:{topic.category}\n\n" # ━━ 第3段:结构约束 ━━ f"结构要求:\n" f"1. 开篇:{opening_rendered}\n" f"2. 核心干货:分3-5段,每段一个小标题+干货内容\n" f"3. 总结收尾:{closing_rendered}\n" f"4. 段落字数:≤{persona.paragraph_max_chars}字\n\n" # ━━ 第4段:风格约束 ━━ f"风格约束:\n" f"- 语气:{persona.writing_style}\n" f"- emoji频率:{persona.emoji_frequency}\n" f"- 禁忌:{banned_rules}\n\n" ) # ━━ 第5段:风格画像(动态注入) ━━ style_hint = StyleLearner.build_style_hint(persona) if style_hint: user_prompt += style_hint + "\n\n" # ━━ 第6段:执行指令 ━━ user_prompt += "请创作正文内容。"6段式 Prompt,每段职责清晰:
| 段 | 职责 | 变化频率 | 来源 |
|---|---|---|---|
| 上下文信息 | 告诉LLM当前环境 | 每个人设不同 | PersonaConfig |
| 任务数据 | 具体要处理的内容 | 每篇文章不同 | Topic + Title |
| 结构约束 | 输出的格式要求 | 相对人设稳定 | PersonaConfig |
| 风格约束 | 输出的风格限制 | 相对人设稳定 | PersonaConfig |
| 风格画像 | 从修改中学习的偏好 | 随使用积累 | StyleLearner |
| 执行指令 | 最终的动作指令 | 固定 | 硬编码 |
为什么不能写成一段?
试想如果把上面6段揉成一段:
你是美妆专家,写一篇关于夏季防晒的文章,风格亲和,emoji中频, 不要用"众所周知",你之前3次要求语气活泼,段落别太长, 开篇要抓眼球,结尾要引导互动,请创作正文内容。
LLM 看到这一坨,大概率会遗漏一半约束。分段 + 标题 + 编号 = LLM 能逐条遵循。
四、约束的艺术:禁忌规则怎么写?
Prompt 中最容易被忽略、但最影响质量的部分 —禁忌规则。
项目中的禁忌规则构建
def _build_banned_rules(self, persona: PersonaConfig) -> str: rules = [] if persona.banned_words: rules.append(f"禁止使用:{', '.join(persona.banned_words)}") if persona.banned_topics: rules.append(f"禁止涉及:{', '.join(persona.banned_topics)}") # 通用禁忌 rules.append("禁止使用'众所周知'、'不言而喻'等AI书面语") rules.append("禁止编造数据或引用不存在的来源") return ";".join(rules)禁忌规则的三层设计:
人设级禁忌:
banned_words+banned_topics,每个人设不同通用禁忌:所有赛道都适用的规则(不编造数据、不用AI书面语)
风格画像禁忌:从修改记录中学到的偏好("我之前3次要求减少emoji")
踩坑:禁忌太松 vs 太严
太松:LLM 编造数据、用"众所周知"、写论文腔 → 读者一眼看出是AI
太严:禁了太多词,LLM 无话可说 → 输出空洞
解法:禁"AI书面语",不禁"专业术语";禁"编造数据",不禁"引用常识"
五、输出格式约束:让LLM输出结构化数据
Prompt 最后一段通常是输出格式要求,这直接决定下游代码能不能解析。
选题生成:要求JSON输出
prompts/topic_generation.md的末尾:
# 输出格式 JSON对象: { "topics": [ { "title": "选题方向", "category": "knowledge/pitfall/comparison/tutorial/experience", "content_format": "适配内容形式", "estimated_potential": "high/medium/low" } ] }标题生成:要求JSON数组 + 额外字段
prompts/title_generation.md的末尾:
# 输出格式 JSON数组:[{"title": "...", "formula_type": "...", "estimated_ctr": 0.0-1.0}]为什么必须指定输出格式?
| 不指定 | 指定 |
|---|---|
| LLM 可能返回一整段散文 | 返回结构化JSON |
| 下游代码无法解析 | json.loads()直接用 |
| 每次格式不同 | 格式稳定可预期 |
但即使指定了JSON,LLM也可能返回 ```json 代码块,所以下游一定要加容错解析:
# 容错解析:去掉 markdown 代码块 result = result.strip() if result.startswith("```"): lines = result.split("\n") lines = [l for l in lines if not l.startswith("```")] result = "\n".join(lines).strip() data = json.loads(result)六、风格画像注入:Prompt 的动态进化
这是项目中最有"Agent味"的 Prompt 设计 —风格画像不是写死在模板里的,是从修改记录中动态学习、动态注入的。
# content/body.py — 风格画像注入 from ..persona.style_learner import StyleLearner style_hint = StyleLearner.build_style_hint(persona) if style_hint: user_prompt += style_hint + "\n\n"
当人设积累了风格画像后,Prompt 会自动多出一段:
【风格画像】 倾向于活泼口语化表达;emoji使用克制;喜欢用数据支撑观点;段落简短有力 【风格偏好提醒】 - 语气:活泼(我之前 3 次要求此调整) - emoji:低频(我之前 2 次要求此调整)
这不是开发者写的 Prompt,是 Agent 自己从用户行为中学到的 Prompt。这就是 Agent 和普通软件的本质区别 — Agent 的 Prompt 会进化。
踩坑总结
| 坑 | 根因 | 修复 |
|---|---|---|
| Prompt 写死在代码里 | 没有模板化 | 抽成.md模板文件 + Jinja2 渲染 |
| 改一个词要改代码发版 | Prompt 和逻辑耦合 | Prompt 独立文件,代码只负责注入变量 |
| LLM 输出格式不稳定 | 没有指定输出格式 | Prompt 末尾加 JSON schema 约束 |
| LLM 返回 ```json 代码块 | LLM 的 markdown 习惯 | 加容错解析,去掉代码块标记 |
| 禁忌太松,AI书面语满天飞 | 通用禁忌缺失 | 加"禁止AI书面语"通用规则 |
| 风格每次随机 | Prompt 没有记忆 | 风格画像动态注入 Prompt |
经验总结
Prompt 是 Agent 的操作系统,System Prompt 定义身份,User Prompt 下达任务,两者分工明确
模板化 + 变量注入是工程化的第一步,改 Prompt 不改代码,不同人设自动适配
多段式 Prompt = 角色设定 + 任务描述 + 约束条件 + 输出格式,分段越清晰,LLM 遵循越好
下篇预告
下一篇讲Pydantic 数据建模— 给 Agent 装上结构化思维,让 LLM 的输出不再"胡说八道"。