写在前面
我长期专注于智能机器人客服软件(冰石)的开发。最近一个保险行业的客户提出了一个非常典型的需求:
同一个 AI 客服,要面对小企业主 、白领 、家庭主妇 等不同客户群,希望对每一类客户都使用不同的话术。
客户的疑问是:到底是给每类客户用不同的提示词 ?还是写一个大提示词,让模型自己判断客户类型再切换话术?
这个问题在做大模型应用落地的同行里其实非常普遍------今天分享一下我们的完整设计思路、踩过的坑,以及一份可参考的工程实现。
一、先回答最初的问题:合并写还是拆开写?
我直接给结论:拆开写,再用一个分类决策层在外部路由。
很多团队一开始会图省事,把所有话术塞进一个 system prompt:
你是保险客服。如果用户是企业主,用 A 话术......
如果用户是白领,用 B 话术......
如果用户是家庭主妇,用 C 话术......
听起来很合理,但上线后会遇到三个问题:
- Token 浪费:每轮对话模型都要"读"完三套话术再挑一套,请求成本飙升。
- 话术串味:本该用主妇话术的回答里,突然冒出"现金流""税务筹划"这种企业主词汇------大模型在多套规则里很容易"漏风"。话术越细,串味越严重。
- 维护痛苦:后期合规要求改某一类话术,改动牵连其他两类,回归测试成本很高。
正确做法是把判定逻辑从提示词里剥离出来,放在外层的代码里完成;模型只专注于"用某一套话术服务这个用户"。
二、整体架构
我们设计的架构是这样的:
┌──────────────────────┐
│ 用户进线 │
└──────────┬───────────┘
│
┌────────────┴────────────┐
│ 分类决策层 Classifier │
│ │
│ 输入: │
│ ① 渠道/标签 │
│ ② 用户自选 │
│ ③ 对话推断 │
│ │
│ 输出:user_type + 置信度│
└────────────┬────────────┘
│
┌────────────┴────────────┐
│ 提示词路由 Router │
│ 按 user_type 加载 │
│ 对应 system prompt │
└────────────┬────────────┘
│
┌────────────┴────────────┐
│ LLM 调用 │
└────────────┬────────────┘
│
┌────────────┴────────────┐
│ 回写:识别到的新信号 │
│ 更新用户画像 │
└─────────────────────────┘
核心思路是:三种来源的信号都写入同一份"用户画像"对象,分类器每轮基于最新画像出结论。
三、数据结构:把"画像"明确下来
第一步先定义清楚每个会话需要维护什么状态。这一步看似简单,但我们经验是:会话状态设计不清晰,后期所有调试都会变成猜谜游戏。
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class UserType(str, Enum):
SMALL_BUSINESS_OWNER = "small_business_owner"
WHITE_COLLAR = "white_collar"
HOMEMAKER = "homemaker"
UNKNOWN = "unknown" # 兜底类型
class Source(str, Enum):
CHANNEL_TAG = "channel_tag" # 渠道带入
USER_DECLARED = "user_declared" # 用户自选
INFERRED = "inferred" # 对话推断
@dataclass
class TypeSignal:
"""一条分类信号"""
user_type: UserType
source: Source
confidence: float # 0.0 ~ 1.0
evidence: str # 依据,便于排查
turn: int # 在第几轮产生
@dataclass
class UserProfile:
"""会话级用户画像"""
user_id: str
session_id: str
signals: list[TypeSignal] = field(default_factory=list)
current_type: UserType = UserType.UNKNOWN
current_confidence: float = 0.0
locked: bool = False # 是否锁定(用户明确声明后可锁定)
几个关键点:
- 每条信号独立保存,不要让新信号直接覆盖旧信号。所有历史信号都留下来,融合时再综合判断。
- evidence 字段必填。生产环境出问题时,这是排查"为什么用了错的话术"的命根子。
- locked 字段:用户明确声明身份后锁定,避免后续推断把它改飞。
四、三个分类信号源的实现
信号源 1:渠道标签(最早可用)
最便宜、最快的信号。会话刚创建时就能拿到:
CHANNEL_TYPE_MAP = {
"boss_community_wechat": UserType.SMALL_BUSINESS_OWNER,
"linkedin_ad": UserType.WHITE_COLLAR,
"parenting_app": UserType.HOMEMAKER,
# ... 其他渠道
}
def init_profile_from_channel(user_id, session_id, channel) -> UserProfile:
profile = UserProfile(user_id=user_id, session_id=session_id)
if channel in CHANNEL_TYPE_MAP:
profile.signals.append(TypeSignal(
user_type=CHANNEL_TYPE_MAP[channel],
source=Source.CHANNEL_TAG,
confidence=0.6, # 中等置信度
evidence=f"channel={channel}",
turn=0,
))
return profile
踩坑提醒 :渠道标签的置信度建议给 0.5~0.7,别给到 0.9。原因很现实:企业主社群里也会混进来打工人,宝妈群里也有自己开店的。给太高会让推断信号没机会修正它。
信号源 2:对话推断(边聊边判断)
这是最重的一块,建议用一个独立的"分类小调用",与主对话解耦------主对话归主对话,分类归分类,互不干扰。
我们提供两套实现,按成本和精度选:
方式 A:规则 + 关键词(便宜,适合冷启动)
KEYWORDS = {
UserType.SMALL_BUSINESS_OWNER: [
"我们公司", "我开了个", "员工", "现金流", "客户款", "营业执照", "老板"
],
UserType.WHITE_COLLAR: [
"我上班", "公司给上的", "五险一金", "公积金", "加班", "打工", "同事"
],
UserType.HOMEMAKER: [
"我家孩子", "我老公", "我老婆", "我不上班", "全职", "带娃", "家庭"
],
}
def rule_based_infer(message: str, turn: int) -> Optional[TypeSignal]:
hits = {t: sum(kw in message for kw in kws) for t, kws in KEYWORDS.items()}
best_type, count = max(hits.items(), key=lambda x: x[1])
if count == 0:
return None
return TypeSignal(
user_type=best_type,
source=Source.INFERRED,
confidence=min(0.4 + 0.15 * count, 0.85),
evidence=f"keyword_hits={count}",
turn=turn,
)
方式 B:LLM 分类(精度高,几分钱一次)
每隔 N 轮跑一次(不必每轮都跑),或在规则没命中时跑:
CLASSIFY_PROMPT = """你是用户分类助手。根据下面的对话历史,判断用户最可能属于哪一类:
A. small_business_owner(小企业主/个体经营者)
B. white_collar(公司白领/上班族)
C. homemaker(家庭主妇/全职照顾家庭)
D. unknown(信息不足)
只输出 JSON,不要任何解释:
{"type": "...", "confidence": 0.0-1.0, "evidence": "一句话依据"}
对话历史:
{history}
"""
def llm_infer(history: list[dict], turn: int) -> Optional[TypeSignal]:
resp = small_llm.complete(CLASSIFY_PROMPT.format(history=format_history(history)))
data = json.loads(resp)
if data["type"] == "unknown":
return None
return TypeSignal(
user_type=UserType(data["type"]),
source=Source.INFERRED,
confidence=data["confidence"],
evidence=data["evidence"],
turn=turn,
)
调用时机建议 :第 1、3、5 轮各跑一次,之后如果置信度已经够高就停。不要每轮都跑,浪费钱。我们实测下来,前 5 轮里基本就能稳定分类,后面再跑性价比很低。
信号源 3:用户自选(最强信号)
在合适时机抛出选项,比如冷启动时或推断置信度长期上不去时:
def ask_user_to_self_identify():
return {
"type": "quick_reply",
"text": "为了给您更精准的建议,方便了解一下您目前的情况吗?",
"options": [
{"label": "我是企业主/自己做生意", "value": "small_business_owner"},
{"label": "我是上班族", "value": "white_collar"},
{"label": "我主要照顾家庭", "value": "homemaker"},
{"label": "暂不方便说", "value": "skip"},
]
}
def on_user_declared(profile: UserProfile, choice: str, turn: int):
if choice == "skip":
return
profile.signals.append(TypeSignal(
user_type=UserType(choice),
source=Source.USER_DECLARED,
confidence=0.95,
evidence="user_self_declared",
turn=turn,
))
profile.locked = True # 锁定,不再被推断覆盖
五、融合逻辑:决策层的核心
收到多个信号后,怎么综合得出 current_type?我们用优先级 + 加权 + 时间衰减的混合策略:
SOURCE_PRIORITY = {
Source.USER_DECLARED: 3,
Source.INFERRED: 2,
Source.CHANNEL_TAG: 1,
}
def decide(profile: UserProfile) -> tuple[UserType, float]:
# 1. 已锁定,直接返回最近一次用户声明
if profile.locked:
latest = next(
(s for s in reversed(profile.signals) if s.source == Source.USER_DECLARED),
None
)
if latest:
return latest.user_type, latest.confidence
# 2. 按类型聚合分数 = sum(置信度 × 来源权重 × 时间衰减)
scores: dict[UserType, float] = {}
current_turn = max((s.turn for s in profile.signals), default=0)
for sig in profile.signals:
weight = SOURCE_PRIORITY[sig.source]
decay = 0.9 ** (current_turn - sig.turn) # 越新的信号权重越高
scores[sig.user_type] = scores.get(sig.user_type, 0) + \
sig.confidence * weight * decay
if not scores:
return UserType.UNKNOWN, 0.0
best_type = max(scores, key=scores.get)
total = sum(scores.values())
confidence = scores[best_type] / total if total else 0.0
return best_type, confidence
设计要点:
- 用户自选最强:权重 3 + 置信度 0.95,几乎一定胜出。
- 推断中等:权重 2,多次一致的推断也能压过渠道标签。
- 渠道最弱:权重 1,只在没其他信息时起作用。
- 时间衰减 :
0.9^Δturn让最近的信号占更大比重,旧信号会被自然稀释------这一点对动态切换很关键。
六、动态切换:避免"话术变脸"翻车
光自动切还不够。生产环境里有一个非常容易翻车的场景:
用户聊到第 4 轮,AI 推断出他是企业主,于是下一轮回复突然换了一套口吻------用户感受是"客服怎么变了一个人",体验崩坏。
我们加了两条规则:
规则 1:切换前先确认(软切换)
如果当前类型变了且新类型置信度 ≥ 0.8,但用户没明确声明过,不要直接换话术,而是插一句确认:
"听您这么说,您是自己开公司是吗?这样我可以从经营保障的角度给您一些建议。"
用户回"对"再切。这一步可以在主提示词里加一段指令:
当系统标注 <pending_type_switch>X</pending_type_switch> 时,
在回复中自然地确认用户身份,而非直接套用 X 类话术。
规则 2:硬冲突时回退到通用版
如果两种类型分数接近(差距 < 20%),用 UNKNOWN 的中性话术,别勉强选一个。这一点尤其重要:选错的代价远大于选"中性"。
七、完整的对话主循环
把上面的部件拼起来,每轮对话长这样:
def handle_turn(session_id: str, user_message: str) -> str:
profile = load_profile(session_id)
turn = profile.current_turn + 1
# 1. 收集本轮信号(推断)
if should_run_inference(turn, profile):
sig = rule_based_infer(user_message, turn) \
or llm_infer(profile.history, turn)
if sig:
profile.signals.append(sig)
# 2. 决策当前类型
new_type, new_conf = decide(profile)
# 3. 判断是否需要软切换
pending_switch = None
if (new_type != profile.current_type
and new_conf >= 0.8
and not profile.locked):
pending_switch = new_type
# 4. 加载对应提示词(共享部分 + 类型专属部分)
prompt_type = profile.current_type if pending_switch else new_type
system_prompt = build_prompt(
base=COMMON_PROMPT,
type_specific=PROMPTS[prompt_type],
pending_switch=pending_switch,
)
# 5. 调用 LLM
reply = llm.chat(system_prompt, profile.history + [user_message])
# 6. 更新画像
profile.current_type = new_type
profile.current_confidence = new_conf
save_profile(profile)
return reply
八、提示词组织:共享部分一定要抽出来
三套话术里,肯定有大量重合内容(公司介绍、合规话术、不能承诺收益、转人工规则等)。我们的做法:
COMMON_PROMPT(共享)
├── 角色定位
├── 合规底线(不能承诺收益、不能误导销售......)
├── 转人工规则
└── 通用礼貌话术
PROMPTS[small_business_owner]
└── 关注点:现金流保障、关键人风险、税务筹划
└── 用语:经营、老板、公司、员工
└── 案例库:......
PROMPTS[white_collar]
└── 关注点:房贷、收入中断、子女教育、补充医疗
└── 用语:上班、加班、同事、职场
└── 案例库:......
PROMPTS[homemaker]
└── 关注点:家庭整体保障、孩子、配偶意外
└── 用语:家人、孩子、老公/老婆
└── 案例库:......
最终 system prompt = COMMON_PROMPT + PROMPTS[user_type]。
好处:合规要求改一次就够了,话术调整也不会牵一发而动全身。
九、上线前的几条工程经验
最后总结几条我们在保险客户项目里踩过的实操经验:
1. 全量记录信号和决策。 signals 列表每条都留存,每次切换都打日志(哪轮、什么依据、从 X 切到 Y)。这是后期调优和 badcase 排查的基础设施,省不得。
2. 先跑数据再调权重。 文章里给的置信度和权重是经验初值,真正的最优解必须等线上数据跑出来。重点关注:渠道标签的实际准确率?规则关键词的误判率?哪类用户最容易误分?
3. 别让分类阻塞主对话。 如果 LLM 分类调用慢或失败,主对话照常用上一轮的类型继续。分类是辅助系统,不能成为关键路径。
4. 给运营留个后门。 后台界面要能手动改某个会话的 current_type 并锁定,应对客服介入或紧急 badcase 兜底。
5. 类型先少后多。 一开始 3 类够用,跑一段时间看数据再细化。一上来分 10 类,话术写不完、效果还不一定更好。
6. 留 UNKNOWN 兜底。 信息不足时用中性版本,别强行套某一类话术,否则用户体验会非常奇怪。
写在最后
回到最初的问题:"是用不同提示词,还是在同一个提示词里判断?"
经过这套架构的拆解,答案其实已经很清楚了------
判断 这件事让代码做,话术这件事让提示词做。 把"我是谁"和"我该说什么"这两件事解耦,整个系统才能扩展和维护。
这套架构我们已经在多个客户项目里验证过,覆盖保险、教育、医美等不同行业的多类客户分群场景。如果你也在做类似的智能客服 / 大模型应用落地,希望这篇文章能给你一些启发。
欢迎在评论区交流你的方案与踩过的坑。
关于作者:冰石智能机器人客服软件开发者,长期专注于智能客服领域的产品研发与大模型落地实践。