如果你做 AI 应用开发超过三个月,大概率遇到过这种场景:某个聊天机器人刚上线时人设很鲜明,聊了几轮就开始“崩人设”;同一套提示词在 A 模型上表现正常,换到 B 模型就变成了另一个性格;一旦接入真实用户,面对各种刁钻输入,角色说的话越来越像通用大模型,不像那个“应该存在的角色”。
问题往往不在模型,而在于很多人把“人设”当成了一段提示词,塞进 system prompt 就完事了。一段文本在单轮对话里也许够用,但在多轮对话、多角色、多模型接入、长期记忆叠加的真实应用里,它既不可维护,也不可评估,更不可复用。
所以我在设计开源人格 AI 框架时,坚持一个核心判断:人设不应该是一段提示词,人设是一套数据结构、一组渲染规则、一道评估约束。换句话说,角色人格是应用层应该管理的东西,而不是大模型随机发挥的结果。
这篇文章我会以我正在开发的一个开源人格 AI 框架为例,拆解它的设计目标、模块边界、落地代码和工程坑点。无论你是想给客服机器人加人设,还是做陪伴类 Agent、游戏 NPC、IP 数字分身,这篇文章都能给你一套可参考的架构思路。
1. 为什么我坚持人设不能只写在 System Prompt 里
先看很多团队最常用的做法:在 system prompt 里写一大段“你是某某,你性格开朗,你喜欢用短句,你要像朋友一样关心用户”。这个方案成本确实低,但它有几个很难绕开的硬伤。
第一,人设和上下文在抢占窗口。大模型的上下文窗口是有限资源。人设描述越长,留给对话历史、知识库内容、用户输入的空间就越小。而当对话历史变长时,模型很容易把最新的用户语境看得比系统设定更重,人设就会逐渐漂移。
第二,人设不可度量。写在 prompt 里的性格描述,你怎么判断模型有没有遵守?你只能靠“感觉”。感觉这个用户说太礼貌了,感觉那个回答不像这个角色。没有任何结构化指标,你就无法迭代,无法做回归测试,无法在升级模型之前判断新版是不是会崩人设。
第三,人设无法复用。如果你的项目不是只有一个机器人,而是要运营十几个 IP、几十个角色,那每个角色的 prompt 都会变成一份各自维护的“文档”。改名要改一段长文本,换风格要重新写一段长文本,角色之间还容易互相抄来抄去,最后变成一坨谁都改不动的提示词泥潭。
第四,prompt 不是强约束。大模型对 system prompt 的遵循程度并不是 100%。尤其是某些能力较弱的模型,或者上下文很长之后,它在风格、语气、价值观边界上的表现会显著下降。如果你真的希望角色不说某些话、不做某些事,你需要的是程序级的守卫,而不只是一句“请你不要……”
这些问题的根源,是把人格当成了“文本”,而不是把人格当成“运行时状态”。一个适合工程化的人格 AI 框架,应该把角色拆成可结构化的配置、可执行的约束和可观测的评估层,而不是把所有东西都堆进一轮模型的输入里。
这也是我在设计框架时确立的出发点:人设要独立于模型,独立于对话流程,独立于具体提示词模板。
2. 人格 AI 框架的核心概念与设计目标
所谓“人格 AI 框架”,我给出的定义是:一组帮助开发者定义、渲染、执行和评估 AI 角色人设的工程组件。它解决的不只是“怎么让模型说话像某个人”,而是“怎么长期稳定、低成本、可维护地让模型像某个人”。
框架我暂时命名为 PersonaKit,目前主要包含四个核心角色:
| 概念 | 说明 | 对应场景 |
|---|---|---|
| Persona Descriptor | 角色描述文件,使用 YAML/JSON 定义角色的身份、风格、规则、背景 | 定义一个客服助手、游戏 NPC |
| Persona Loader | 负责加载和校验描述文件的模块,在项目启动时把配置变成运行时对象 | 初始化角色配置 |
| Context Renderer | 将角色配置渲染成模型输入上下文,控制哪些信息进入窗口 | 多轮对话的 system prompt 构建 |
| Consistency Evaluator | 对模型输出做角色一致性评估,输出结构化分数和违规项 | 上线前回归测试、线上抽检 |
同时,框架还需要包含一个很轻量的内存管理约定,用于决定哪些“角色记忆”和“用户记忆”可以在对话中保留。这个模块不一定非要实现得像 RAG 一样复杂,但如果完全没有记忆管理,人设的一致性很难维持。
设计目标有三个层次。
第一层是“开箱即用”。开发者写好一份 YAML,启动一个服务,就能得到一个有稳定人设的聊天接口。
第二层是“可插拔”。框架只负责人格相关的能力,大模型接入、向量库、消息队列这些部分都通过适配器接进来,不强迫你绑定某个具体模型或框架。
第三层是“可评估”。每一次模型回复都可以附带一个人格一致性评分。你可以把这个分数写入日志,也可以做成看板,更可以做成上线前自动检查的一部分。
这个设计有一层重要的取舍:框架不做情绪判断,只做约束判定。也就是说,它不试图判断回答“好不好”,它只判断回答“是否符合当前角色设定”。这是为了降低误判率,也让开发者有更大的解释空间。
3. 整体架构与模块划分
PersonaKit 的模块设计遵循一个很朴素的原则:加载、渲染、执行、评估。四个环节各司其职,彼此之间只通过标准数据结构通信。
整体上可以分为这样几层:
- 配置层:存放角色描述文件、记忆策略、模型接入配置。
- 运行时层:包含加载器、渲染器、LLM 适配器、评估器。
- 服务层:对上层业务提供统一 API,可以嵌入 FastAPI 应用,也可以作为 Agent 框架的一个组件。
- 观测层:记录每次请求的上下文内容、token 消耗、一致性评分、违规项。
这里最重要的一点是:渲染器只能从配置和对话状态中读取数据,不直接读取原始 prompt 文件。这样做的好处是,所有角色信息都经过结构化的数据流,方便做脱敏、过滤和审计。
另外一个关键设计是“记忆命名空间分离”。用户画像属于用户侧,角色背景属于角色侧,两者不能混在一个桶里。如果角色的“小时候住在海边”和用户说的“我小时候住在海边”被放进同一段记忆文本,模型极容易把角色记忆当成用户信息,产生幻觉。这个问题在真实项目里非常常见,我一开始也踩过。
模块的调用时序大致是这样的:业务请求进来,先找对话历史;然后加载角色配置;渲染器把角色配置、记忆、对话历史合并成 context;LLM 生成回复;评估器对回复打分;最后日志模块把完整链路信息落盘。整个链路都是同步的,但每个模块都保留异步执行的空间,方便后续接入异步消息队列。
按照这个模块划分,即使某个环节出现问题,也可以快速定位:人设不对就检查配置,风格飘了就看渲染器,模型不听话就检查评估规则和硬性约束。
4. 环境准备与项目目录结构
在进入代码之前,先把环境说清楚。以下是一个最小可运行环境,版本以实际项目为准,本文演示的是通用思路。
- 操作系统:Linux / macOS / Windows 均可,建议 Linux 或 WSL。
- Python:3.10 及以上。
- 大模型接口:任意支持 OpenAI 兼容接口的服务,线上也可以使用本地部署模型。
- 依赖库:PyYAML、Pydantic、FastAPI、Uvicorn、OpenAI SDK。
你可以先创建虚拟环境,然后安装依赖:
python3 -m venv .venv source .venv/bin/activate pip install pyyaml pydantic fastapi uvicorn openai项目目录我推荐这样组织:
personakit/ ├── api.py # FastAPI 接入层 ├── core/ │ ├── __init__.py │ ├── persona.py # 角色数据模型 │ ├── loader.py # 加载器 │ ├── renderer.py # 渲染器 │ └── evaluator.py # 一致性评估器 ├── llm/ │ ├── __init__.py │ └── client.py # LLM 适配器 ├── personas/ │ └── assistant.yaml # 角色配置示例 ├── requirements.txt └── run_demo.py # 命令行验证脚本这个目录适合中小型项目。如果你的角色数量超过 50 个,建议再拆一层数据库存储,把 YAML 文件作为“模板”,数据库里的行记录作为“实例”。但第一版不需要过度设计,文件化配置反而更容易上手和审查。
依赖文件 requirements.txt 内容如下:
pyyaml>=6.0 pydantic>=2.0 fastapi>=0.110 uvicorn>=0.29 openai>=1.30这里不建议把版本锁得过死。大模型 SDK 更新很快,锁死版本容易错过上游修复;但也不要完全不锁,至少给出一个经过验证的下限。
5. 核心流程拆解:从人设文件到一轮应答
我以最小可用的实现来拆解核心流程。一个角色想要“立住”,至少要有四个步骤:定义、加载、渲染、评估。
5.1 写一份人设文件
先看一份最简人设文件。不要写几十条抽象形容词,而是把角色定义成可观察的行为和边界。
# 文件路径:personas/assistant.yaml persona: name: "云舟" role: "开源社区技术支持助手" background: "云舟是开源社区的资深维护者,熟悉分布式系统和开发者工具,回答问题时习惯先给结论再展开细节。" speech_style: - "使用简洁的中文短句,可以直接给出命令和代码。" - "称呼用户为「你」,不使用「亲」「宝宝」等过度亲昵表达。" - "回答末尾可以给出进一步阅读方向,但不使用营销语气。" hard_rules: - "不编造不存在的 API、项目名或版本号。" - "不确定的信息必须明确说明不确定,不能强行猜测。" - "不输出与开源技术无关的敏感话题。" memory: enabled: true max_turns: 8这里最关键的是hard_rules。这组规则不是“建议模型参考的”,而是要进入渲染器并且后续评估时重点检查的。风格可以灵活,但硬性规则不能违反。
5.2 加载器与数据模型
使用 Pydantic 定义角色模型,并在加载时做字段校验:
# 文件路径:core/persona.py from pydantic import BaseModel, Field from typing import List, Optional class MemoryConfig(BaseModel): enabled: bool = True max_turns: int = 8 class Persona(BaseModel): name: str role: str background: str speech_style: List[str] = Field(default_factory=list) hard_rules: List[str] = Field(default_factory=list) memory: MemoryConfig = Field(default_factory=MemoryConfig)加载器只需要负责读取 YAML 并转换成 Persona 对象,不做其他事。
# 文件路径:core/loader.py import yaml from pathlib import Path from core.persona import Persona def load_persona(path: str | Path) -> Persona: path = Path(path) data = yaml.safe_load(path.read_text(encoding="utf-8")) return Persona(**data["persona"])这个模块极其简单,但它承担了一个重要职责:它在项目启动时就会把所有角色配置校验一遍。如果某个角色文件写错了,启动时直接报错,而不是运行到一半才发现角色“坏了”。
5.3 渲染器:把配置变成上下文
渲染器的任务是控制“模型看到什么”。我的建议是,把角色背景、说话风格、硬性规则、记忆摘要分别拼成四个较小的 Section,而不是把所有内容揉成一段长文本。
# 文件路径:core/renderer.py from core.persona import Persona from typing import List def render_context(persona: Persona, memory_text: str = "") -> str: style_lines = "\n".join(f"- {s}" for s in persona.speech_style) rule_lines = "\n".join(f"- {r}" for r in persona.hard_rules) system_prompt = f"""你正在扮演一个具有稳定人设的 AI 角色,请严格按照下面的设定说话。 ## 角色身份 {persona.name}:{persona.role} ## 角色背景 {persona.background} ## 说话风格 {style_lines} ## 硬性约束 {rule_lines} """ if persona.memory.enabled and memory_text: system_prompt += f""" ## 可参考的相关记忆 {memory_text} """ return system_prompt渲染器不负责调用模型,也不负责修改对话历史。它的输出就是一个字符串。这样做的好处是,你可以在日志里看到完整的 prompt 内容,排查“模型为什么说话怪怪的”时,可以先看是不是渲染出了问题。
5.4 LLM 适配器
为了不绑定某一家模型服务,我会封装一个极简的 OpenAI 兼容客户端:
# 文件路径:llm/client.py from openai import OpenAI from dataclasses import dataclass @dataclass class LLMConfig: base_url: str api_key: str model: str class LLMClient: def __init__(self, config: LLMConfig): self._client = OpenAI( base_url=config.base_url, api_key=config.api_key, ) self._model = config.model def chat(self, messages: list[dict], temperature: float = 0.7) -> str: resp = self._client.chat.completions.create( model=self._model, messages=messages, temperature=temperature, ) return resp.choices[0].message.content如果是在本地部署模型,只要服务提供 OpenAI 兼容接口,这里只需要换 base_url 即可。注意,不要写死模型名,模型的选用应该由部署配置文件控制。
6. 完整示例:给 AI 智能体接入人格层
下面把一个可以运行的最小服务串起来。目标是:启动一个 FastAPI 服务,提交用户消息后返回带人设的 AI 回复,并且带上人设一致性评分。
6.1 对话状态管理
为了演示简单,先用一个内存字典保存历史消息。生产环境建议换成 Redis 或数据库。
# 文件路径:api.py(第一部分) from fastapi import FastAPI from pydantic import BaseModel from typing import List from core.loader import load_persona from core.renderer import render_context from core.evaluator import evaluate_consistency from llm.client import LLMClient, LLMConfig app = FastAPI() persona = load_persona("personas/assistant.yaml") llm = LLMClient( LLMConfig( base_url="http://localhost:8001/v1", api_key="sk-local", model="local-model-name", ) ) # 演示用内存会话管理 sessions: dict[str, list[dict]] = {} class ChatRequest(BaseModel): session_id: str user_message: str6.2 对话接口
这里多了一个关键步骤:从历史消息里抽取“记忆摘要”。为了控制示例复杂度,我会直接使用最近的对话历史作为上下文,而不是做完整的记忆归纳。实际项目里,这一层可以改成调用另一个 LLM 或向量检索。
# 文件路径:api.py(第二部分) @app.post("/chat") def chat(req: ChatRequest): history = sessions.get(req.session_id, []) history.append({"role": "user", "content": req.user_message}) # 渲染人设上下文 memory_text = "" if persona.memory.enabled: recent = history[-(persona.memory.max_turns * 2):] memory_text = "\n".join( f"{m['role']}: {m['content']}" for m in recent ) system_prompt = render_context(persona, memory_text=memory_text) messages = [{"role": "system", "content": system_prompt}] + history # 请求模型 reply = llm.chat(messages) history.append({"role": "assistant", "content": reply}) sessions[req.session_id] = history # 人设一致性评估 eval_result = evaluate_consistency( persona=persona, dialogue=history, response=reply, ) return { "reply": reply, "persona_score": eval_result["score"], "violations": eval_result["violations"], "session_id": req.session_id, }注意,这里把历史消息和 system prompt 同时传给模型。有些模型会把最近的 user 消息权重放得更高,所以如果用户消息很长,而历史已经很复杂,人设仍然有被覆盖的风险。缓解手段是限制历史长度,同时把硬性规则放在 system prompt 靠后的位置——部分模型对 prompt 开头和结尾的注意力更高。
6.3 一致性评估器
评估器是人格框架区别于普通 prompt 工程的重要模块。它使用一个独立的 LLM 调用,把回复与角色硬性规则进行核对,并输出结构化 JSON。
# 文件路径:core/evaluator.py import json from core.persona import Persona from llm.client import LLMClient, LLMConfig _evaluator_llm = LLMClient( LLMConfig( base_url="http://localhost:8001/v1", api_key="sk-local", model="eval-model-name", ) ) def evaluate_consistency(persona: Persona, dialogue: list[dict], response: str) -> dict: rule_text = "\n".join(f"- {r}" for r in persona.hard_rules) style_text = "\n".join(f"- {s}" for s in persona.speech_style) history_text = "\n".join( f"{m['role']}: {m['content']}" for m in dialogue[-6:] ) prompt = f"""你是一个 AI 角色一致性评测员。请判断最新回复是否符合角色设定。 ## 角色身份 名称:{persona.name} 角色:{persona.role} ## 说话风格 {style_text} ## 硬性约束 {rule_text} ## 对话历史 {history_text} 请输出 JSON,格式为: {{"score": 0到1之间的小数, "violations": ["违规项1", "违规项2"]}} 只输出 JSON,不要输出解释。""" raw = _evaluator_llm.chat( [{"role": "user", "content": prompt}], temperature=0.0, ) try: result = json.loads(raw) except json.JSONDecodeError: result = {"score": 0.0, "violations": ["评估器输出无法解析"]} return result这里有一点值得注意:temperature=0.0是为了让评估输出尽量稳定。评估结果本身也不是绝对真理,它更适合作为“相对指标”,用来回归对比不同模型、不同配置,而不是用来给单条回复下最终判断。
6.4 命令行验证脚本
为了快速验证,我也写了一个命令行脚本,避免每次都要启动服务:
# 文件路径:run_demo.py from core.loader import load_persona from core.renderer import render_context from core.evaluator import evaluate_consistency from llm.client import LLMClient, LLMConfig def main(): persona = load_persona("personas/assistant.yaml") llm = LLMClient( LLMConfig( base_url="http://localhost:8001/v1", api_key="sk-local", model="local-model-name", ) ) history = [] while True: user_input = input("你:") if user_input.strip() in ("exit", "quit"): break history.append({"role": "user", "content": user_input}) sys_prompt = render_context(persona) reply = llm.chat( [{"role": "system", "content": sys_prompt}] + history ) history.append({"role": "assistant", "content": reply}) print(f"{persona.name}:{reply}") print(f"[eval] {evaluate_consistency(persona, history, reply)}") if __name__ == "__main__": main()运行这个脚本之前,确保你有可用的 OpenAI 兼容服务地址。如果还没有本地模型,也可以把 base_url 指向云服务商的兼容网关。
7. 运行结果与效果验证
框架接入后,第一件事不是调优对话效果,而是先验证整条链路是否跑通。建议按下面顺序观察。
先启动命令行脚本:
python run_demo.py正常交互时,你会看到类似下面的输出结构(字段为示意,实际以模型输出为准):
你:云舟,我们项目用 Python 3.12,要怎么引入 pydantic 依赖? 云舟:直接使用 pip install "pydantic>=2.0" 即可。如果你使用 uv,也可以自动解析。 [eval] {'score': 0.95, 'violations': []}如果一切正常,说明五个核心环节都能工作:加载器读取配置、渲染器生成上下文、LLM 适配器拿到回复、评估器给出分数、内存会话保存了历史。
但我们需要验证的不仅是“能跑”,更是“人设稳不稳”。这一步要设计一个小型回归集,比如准备 20 个典型用户问题,分别覆盖:技术询问、模糊表达、诱导角色说出人设外的内容、情绪化表达等。对每个问题跑一遍,记录人设一致性评分。
我建议在做回归时关注两个指标:
- 平均一致性分数:如果低于 0.7,说明人设配置或者模型选择需要调整。
- 违规项种类:如果全部都是同类违规,比如“回答语气过度热情”,那问题大概率出在
speech_style表达得不够清楚,而不是模型本身不行。
如果你不想引入复杂评测平台,直接把评估结果写进日志就够了:
{ "session_id": "abc-123", "reply_tokens": 128, "persona_score": 0.92, "violations": [], "context_length": 815 }把这些日志按天聚合,你就能看到人设稳定性随时间、版本、模型切换的变化趋势。这个能力是“把人格当数据”带来的最直接好处。
8. 常见问题与排查思路
实际操作中,你大概率会遇到下面这些问题。我把它们整理成表格,方便快速定位。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 人设一致性评分长期偏低 | 角色配置里的规则表达太抽象,模型无法执行 | 查看评估器返回的 violations,确认具体违规项 | 把抽象描述改成可观察行为,减少规则数量 |
| 模型答非所问或幻觉严重 | 历史上下文被截断,或记忆摘要里混入了角色自己的信息 | 检查渲染器输出的 memory_text 是否干净 | 隔离子角色记忆和用户记忆,限制 max_turns |
| 换了模型后人设完全变了 | 不同模型对 system prompt 的遵循能力不同 | 用同一批回归集跑新旧模型,对比评分 | 不强求所有模型表现一致,用评分选择可接受模型 |
| 启动加载角色配置失败 | YAML 缩进错误或字段类型不匹配 | 检查控制台异常堆栈 | 给每个角色配置做 CI 校验,提交前自动检查 |
| 上下文 token 爆炸 | 人设描述过长,加上完整历史记录 | 观察渲染器的 context_length 日志 | 压缩背景描述,历史只保留最近窗口 |
| 角色回复过于机械 | 硬性规则过多且自相矛盾 | 审查 hard_rules 是否互相冲突 | 保留 5 条左右核心规则,风格交给 speech_style |
还有一个很隐蔽的问题:评估模型和生成模型用同一个服务,会带来评估偏差。如果生成模型本身能力弱,评估模型可能因为“和自己的输出风格相近”而给出虚高分数。更稳妥的做法是评估走一个独立的、偏向判别任务的模型,或者至少使用不同的 temperature 参数。
9. 工程化与安全建议
框架能在 demo 里跑通不算本事,真正难的是长期稳定地在生产环境运行。这里给几条工程化建议。
首先,把角色配置纳入版本管理,并做配置评审。角色配置就是产品逻辑的一部分,修改一个角色的性格,等同于修改业务逻辑。它应该走和代码一样的 MR/PR 流程,而不是由运营直接改线上文件。
其次,最少暴露原则。人格框架不应该直接暴露到底层模型的全部能力。比如你给客服机器人设定的人格,不应该让它能调用内部 API 或读取敏感字段。这需要独立的权限层来兜底,而不是指望人设约束。
第三,评估结果要有审计链路。每一条线上回复都应该记录模型版本、角色配置版本、评估分数、上下文长度。这样一旦出现问题,你可以快速定位是哪个版本引起的,回滚也会很清晰。
第四,不要让人设约束替代安全审核。即使某一个角色被设计成“毒舌”“犀利”,也不意味着它可以输出攻击性内容。安全过滤应该独立于人设层,永远在最后一道关卡把关。
第五,注意多角色场景下的 prompt 复用误区。如果你有多个角色,一定不要把角色 A 忘记说的话留到角色 B 的上下文里。更稳妥的做法是每一个角色都使用完全隔离的上下文命名空间。
10. 总结与下一步实践方向
回到最初的问题:为什么开源人格 AI 框架值得自己搭一层,而不是继续依赖一段 system prompt?
我的答案是,当你的项目只有一个角色、一轮对话、一个模型时,提示词就够用了。但当你需要维护多个角色、多轮长对话、多个模型版本、需要持续回归人设稳定性时,你必须有结构化的角色配置、独立的渲染层和一致性的评估机制。这恰恰是人格框架要解决的问题。
你在这一步可以做的事情很清楚:先复制我这份代码,把一个人设文件改造成自己产品的角色;然后准备 20 个典型问题做回归;最后把评估结果接入日志。这套最小闭环跑通之后,你就能直观感受到“人格数据化”和“人格文本化”的差别。
如果继续深入,我建议下一步探索这三个方向:一是记忆模块的升级,从滑动窗口改成基于向量检索的长期记忆,但记忆数据必须继续强调角色与用户的命名空间隔离;二是评估器的改进,引入更细粒度的人设维度,比如语气、价值观、知识边界;三是多角色调度,让一个应用里可以动态切换多个角色,并保证切换后不串人格。
人格 AI 这个方向还很年轻,我不认为存在一套最好的框架。最好的框架应该是那个能让你的人设可配置、可评估、可演进,并且在真实项目里长期不崩的框架。希望这篇文章能给你一个可以参考的起点。