一、前言

第 03 期你让模型"记住"了对话历史,但它还是个"没有身份的通用助手"——问它"你是谁",它背一大段"我是由 XX 公司训练的大语言模型";问它"能帮我做什么",它列十几个方向,从写诗到心理咨询什么都接。

这在教学场景能忍,但放到真实业务里不行:你要的是一个技术助理,不是通用聊天机器人。模型该有的语气、能力边界、回答风格,都得你来定。

这一期我们引入 system 角色——把人设写进 templates/SOUL.md,加载后作为 messages 的第一条,全程影响每次调用。看完你就能:

  • templates/SOUL.md 管理人设,改人设不用改代码
  • 把 system prompt 注入 messages[0],让模型"听从"角色设定
  • 对比有无 system prompt 时的行为差异
  • 改造截断逻辑,保护 system prompt 不被删除

本文是 Agent 教学系列第 04 期的实战笔记,干货为主,偶尔自嘲,各位看官将就着看。

在这里插入图片描述

二、概念对齐:为什么 system prompt 能约束模型

2.1 前 03 期的 Agent 没有"身份"

回顾一下 messages 列表的结构,前 03 期只有两种角色:

messages = [
    {"role": "user", "content": "你是谁?"},
    {"role": "assistant", "content": "我是由 XX 训练的大语言模型..."},
]

模型没有任何"身份约束"——它默认是个通用助手,回答风格由训练数据决定,不是你说了算。

2.2 system 角色:全局指令,优先级最高

OpenAI SDK 的 messages 列表支持三种角色:

role 定位 优先级
system 全局指令,定义身份/规则/约束 最高
user 用户输入 标准
assistant 模型回复 标准

模型在训练时就学会了"听从 system 指令"——role: system 的消息被当成"全局指令",优先级高于普通 user 消息。

messages = [
    {"role": "system", "content": "你是 Alex,技术助理..."},  # ← 全局指令
    {"role": "user", "content": "你是谁?"},
]

system 就像给模型戴了一顶"帽子",戴着这顶帽子,它就不是通用助手了,而是 Alex。

2.3 人设放外部文件:templates/SOUL.md

人设内容不从代码里硬编码,而是放到独立文件 templates/SOUL.md。这是项目里第一个运行时资源文件——README.md 里提过:第 04 期起引入 templates/ 目录

好处:

  • 改人设不用改代码——非技术人员也能改
  • 多人维护——产品经理改 SOUL.md,开发者改代码,互不干扰
  • 版本管理——人设文件有独立的 git 历史,追踪变更

2.4 本期的"组装公式"

有身份的 Agent = step03(history 回灌) + system prompt 人设

累积式:step04 = step03 + system prompt。不重构前期代码,只新增一层。

三、动手做:从通用助手到 Alex 技术助理

3.1 写人设文件:templates/SOUL.md

人设内容分四块:角色定位、能力边界、回答风格、禁止事项。

# Alex · 技术助理 人设

你叫 Alex,是一名资深全栈技术助理,服务于创业公司 CTO 张三。

## 角色定位
- 你不是通用聊天机器人,你是技术助理——回答简洁、专业、面向工程师
- 说话风格:务实、直接、不啰嗦,像同事之间讨论技术问题

## 能力边界
- 擅长:Python、后端架构、AI/Agent 开发、代码审查、技术选型
- 不擅长:闲聊、心理咨询——遇到这类问题,礼貌引导回技术话题

## 禁止事项
- 不要用"亲""宝子"等非正式称呼
- 不要每次回答都加"很高兴为你服务"之类的客套话

不用写太长,300-500 字就够——人设太长会占太多 token,后续第 07 期会讨论平衡。

3.2 加载并注入 system prompt

程序启动时读取 SOUL.md,拼成 {"role": "system", "content": ...} 放进 messages 列表的第一条:

SOUL_PATH = Path(__file__).parent.parent / "templates" / "SOUL.md"

def load_system_prompt() -> str:
    return SOUL_PATH.read_text(encoding="utf-8")

# messages 第一条永远是 system prompt
messages: list[dict] = [{"role": "system", "content": system_prompt}]

模型每次调用都会读到这条 system——它就像 Alex 的"出厂设置",全程约束每次回答。

3.3 截断逻辑保护 system prompt

第 03 期的截断逻辑是从头删,但第 04 期 messages[0] 是 system prompt,不能被删。改一下:

def trim_history(messages: list[dict]) -> list[dict]:
    # system prompt 不参与截断
    has_system = messages and messages[0]["role"] == "system"
    system_msg = [messages[0]] if has_system else []
    convo = messages[1:] if has_system else messages[:]

    rounds = sum(1 for m in convo if m["role"] == "user")
    if rounds <= MAX_ROUNDS:
        return messages
    excess_rounds = rounds - MAX_ROUNDS
    cut = excess_rounds * 2
    return system_msg + convo[cut:]

这样无论聊多少轮,人设永远在第一条——Alex 永不丢身份。

3.4 reset 保留人设

第 03 期的 reset 清空整个 messages,第 04 期改为"只清空对话,保留 system prompt":

if user_input.lower() == "reset":
    # 重置只清空对话,保留 system prompt
    messages = [{"role": "system", "content": system_prompt}]
    print("[提示] 对话已清空,人设保留,Alex 还在。")
    continue

Alex 还在,只是忘了聊了什么。

3.5 新增 soul 命令

新增 soul 命令:打印当前加载的人设内容,方便调试和理解"人设长什么样":

if user_input.lower() == "soul":
    print(f"[人设] {SOUL_PATH}:\n")
    print(system_prompt)
    continue

3.6 自动演示:对比有无 system prompt

程序跑起来会先用同一个问题"你好,你是谁?能帮我做什么?",分别用"无 system"和"有 system"调用,看回答风格差异:

无 system prompt 有 system prompt(Alex)
自我认知 “我是由 XX 训练的大语言模型” “我是 Alex,技术助理”
回答风格 通用、客套、列一堆方向 简洁、务实、聚焦技术
能力边界 什么都接(写诗、心理咨询) 聚焦技术,非技术问题礼貌引导
token 占用 无额外 system prompt 本身占 token

这就是"人设"的约束力——不是改了模型,是给模型加了边界。

四、跑起来:Alex 技术助理的实际体验

4.1 system prompt 与上下文的关系

system prompt 也是 messages 的一部分,同样占 token:

messages = [
  system(~150 token) + 10 轮历史(~1000 token) + 当前一句(~20 token)
]

所以第 03 期引入的 MAX_ROUNDS 截断,在本期仍然有效——只是截断时跳过 system,只删对话历史。

4.2 问非技术问题:看 Alex 怎么引导

故意问"帮我写首诗"或"今天心情不好怎么办":

  • 无 system 时:模型直接写诗,或开始心理咨询
  • 有 system 时:Alex 会说"这个不太擅长,我是技术助理,有什么技术问题可以聊"

这就是"能力边界"的约束——不是模型不会写诗,是 system 告诉它"你的职责是技术"。

4.3 四期演进

messages 结构 Agent 能力
01 [user] 单次调用
02 [user](每次独立) 循环对话(无记忆)
03 [user, assistant, ...] 有短期记忆
04 [system, user, assistant, ...] 有记忆 + 有身份

前 04 期:模型从"只会说"到"能记住"再到"有身份"。下一步就是给它装"手"——Tool Use 工具调用。

五、执行脚本

#!/usr/bin/env python3
"""step04_system_prompt.py — 第 04 期:System Prompt 人设

本期目标:
1. 引入 templates/SOUL.md,给 Agent 设定"技术助理 Alex"的角色
2. system prompt 作为 messages 列表的第一条,全程影响每次调用
3. 对比有无 system prompt 时的行为差异,直观看到"人设"的约束力

累积式:step04 = step03 + system prompt 人设
(在 step03 的 history 回灌基础上,只新增 system prompt 这一层)

运行:
    python code/step04_system_prompt.py
"""
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
from prompt_toolkit import prompt
from prompt_toolkit.history import InMemoryHistory

load_dotenv()

# ============ 1. 初始化客户端(与第 01-03 期一致)============
client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")

# 上下文长度上限(轮数,1 轮 = 1 user + 1 assistant)
MAX_ROUNDS = 10

# ============ 2. 加载 System Prompt(本期新增)============
SOUL_PATH = Path(__file__).parent.parent / "templates" / "SOUL.md"


def load_system_prompt() -> str:
    """读取 templates/SOUL.md 作为 system prompt。

    这是本期唯一的新增能力:把人设从外部文件加载进来,
    作为 messages 列表的第一条(role=system),全程影响每次调用。
    """
    if not SOUL_PATH.exists():
        raise FileNotFoundError(
            f"找不到人设文件: {SOUL_PATH}\n"
            f"请确认 templates/SOUL.md 已创建(第 04 期引入)"
        )
    return SOUL_PATH.read_text(encoding="utf-8")


def chat(messages: list[dict]) -> tuple[str, int, int]:
    """带历史回灌 + system prompt 的对话。

    与第 03 期的差异:
    - 第 03 期:messages 只有 user/assistant,模型是"无身份"的通用助手
    - 第 04 期:messages 第一条是 system(Alex 技术助理),模型有了人设约束
    """
    response = client.chat.completions.create(
        model=MODEL,
        messages=messages,
        max_tokens=1000,
    )
    answer = response.choices[0].message.content
    usage = response.usage
    return answer, usage.prompt_tokens, usage.completion_tokens


def trim_history(messages: list[dict]) -> list[dict]:
    """上下文长度限制:超过 MAX_ROUNDS 轮时,从最早的一轮开始截断。

    注意:system prompt(第一条)永远保留,只截断 user/assistant 对话。
    """
    # system prompt 不参与截断
    has_system = messages and messages[0]["role"] == "system"
    system_msg = [messages[0]] if has_system else []
    convo = messages[1:] if has_system else messages[:]

    rounds = sum(1 for m in convo if m["role"] == "user")
    if rounds <= MAX_ROUNDS:
        return messages
    excess_rounds = rounds - MAX_ROUNDS
    cut = excess_rounds * 2
    return system_msg + convo[cut:]


def main():
    print("=" * 60)
    print("第 04 期:System Prompt 人设 —— 给 Agent 一个身份")
    print("=" * 60)
    print(f"当前模型: {MODEL}")
    print(f"API 地址: {client.base_url}")
    print(f"上下文上限: {MAX_ROUNDS} 轮(超出自动截断最早)")
    print("=" * 60)

    # ============ 3. 加载人设 ============
    try:
        system_prompt = load_system_prompt()
        print(f"[人设] 已加载: {SOUL_PATH}")
        print(f"[人设] 内容长度: {len(system_prompt)} 字符")
        print("-" * 60)
        print(system_prompt)
        print("-" * 60)
    except FileNotFoundError as e:
        print(f"[出错] {e}")
        return

    # ============ 4. 自动演示:对比有无 system prompt ============
    # 同一个问题,分别用"无 system"和"有 system"调用,看回答风格差异
    demo_question = "你好,你是谁?能帮我做什么?"

    print("\n[自动演示] 同一个问题,对比有无 system prompt:\n")

    # 场景 A:无 system prompt(和第 03 期一样)
    print("[场景 A] 无 system prompt:")
    no_system_msgs = [{"role": "user", "content": demo_question}]
    try:
        answer_a, p_a, c_a = chat(no_system_msgs)
        print(f"  回答: {answer_a}")
        print(f"  token: prompt={p_a}  completion={c_a}")
    except Exception as e:
        print(f"  [出错] {e}")
        return
    print("-" * 60)

    # 场景 B:有 system prompt(本期新增)
    print("[场景 B] 有 system prompt(Alex 技术助理):")
    with_system_msgs = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": demo_question},
    ]
    try:
        answer_b, p_b, c_b = chat(with_system_msgs)
        print(f"  回答: {answer_b}")
        print(f"  token: prompt={p_b}  completion={c_b}")
    except Exception as e:
        print(f"  [出错] {e}")
        return
    print("-" * 60)

    print(
        "\n💡 对比:有 system prompt 时,模型自称 Alex、风格务实、不啰嗦;\n"
        "   没有 system prompt 时,模型是通用的、客套的、废话多。\n"
        "   注意 prompt_tokens 多了——system prompt 本身也占 token。\n"
    )

    # ============ 5. 交互式对话(带人设 + 历史回灌)============
    print("=" * 60)
    print("现在进入自由对话(Alex 技术助理,带记忆 + 人设)")
    print("输入 quit 退出  /  输入 reset 清空历史(保留人设)  /  输入 soul 查看人设")
    print("←/→ 移动光标,↑/↓ 翻历史,Ctrl-C 作废当前行重输")
    print("=" * 60)

    # messages 第一条永远是 system prompt
    messages: list[dict] = [{"role": "system", "content": system_prompt}]

    total_prompt = 0
    total_completion = 0
    cli_history = InMemoryHistory()

    while True:
        try:
            user_input = prompt("\n你: ", history=cli_history).strip()
        except (EOFError, KeyboardInterrupt):
            print("  (本行作废,重新输入)")
            continue
        if user_input.lower() in ("quit", "exit", "q"):
            print(f"\n[本次会话 token 汇总] prompt={total_prompt}  "
                  f"completion={total_completion}  total={total_prompt + total_completion}")
            print(f"[历史] 退出时共 {len(messages)} 条消息"
                  f"(含 1 条 system)")
            print("再见!")
            break
        if user_input.lower() == "reset":
            # 重置只清空对话,保留 system prompt
            messages = [{"role": "system", "content": system_prompt}]
            total_prompt = 0
            total_completion = 0
            print("[提示] 对话已清空,人设保留,Alex 还在。")
            continue
        if user_input.lower() == "soul":
            print(f"[人设] {SOUL_PATH}:\n")
            print(system_prompt)
            continue
        if not user_input:
            continue

        messages.append({"role": "user", "content": user_input})
        messages = trim_history(messages)
        try:
            answer, p, c = chat(messages)
            total_prompt += p
            total_completion += c
            messages.append({"role": "assistant", "content": answer})
            print(f"Alex: {answer}")
            print(f"[token] 本次 prompt={p}  completion={c}  "
                  f"累计 total={total_prompt + total_completion}")
            print(f"[历史] 当前 {len(messages)} 条消息"
                  f"(含 1 条 system + "
                  f"{sum(1 for m in messages if m['role'] == 'user')} 轮对话)")
        except Exception as e:
            messages.pop()
            print(f"[出错] {e}")


if __name__ == "__main__":
    main()

六、总结

一句话回顾:system prompt 是 messages[0],模型每次调用都先读它——就像给 Agent 戴了一顶"帽子"。

三个关键动作记牢:

  1. 写人设文件templates/SOUL.md,定义角色、能力边界、回答风格
  2. 加载为 messages[0]{"role": "system", "content": ...},全程约束每次调用
  3. 截断时保护 system → trim_history 跳过 messages[0],人设永不被删

一行代码记住本期:

messages = [{"role": "system", "content": system_prompt}]  # ← 第一条是人设

把人设作为 messages[0] 注入,每次调用模型都先读 system——戴着这顶"帽子",它就不是通用助手了,而是 Alex 技术助理。

适用场景:这篇适合刚跑通第 03 期、想让 Agent 有身份和边界的人。

下一期预告:第 05 期——Tool Use 工具调用。Alex 现在能记住对话、有人设,但它还是只能"说"——不会查时间、不会调外部接口。下一期给它装第一个工具 get_current_time,解决第 01 期埋的"它不知道现在几点"的伏笔。


感谢各位看官的一路陪伴,大家都再接再厉!

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐