这是山东大学软件学院创新项目实训进入第二阶段后的开发记录。系统的目标没变:用UE搭建3D虚拟诊室,由LLM驱动一位会“带情绪、有隐瞒、会纠结”的标准化患者,让医学生能在里面反复练习医患沟通,结束后拿到一份结构化的沟通能力评价报告。这一篇的内容,主要是LLM对话链路怎么真正接进UE、患者角色怎么“演得像”、评价模块怎么从主观打分变成可解释的反馈。如果你也在做医疗教育信息化、虚拟仿真实训,或者正在折腾把大模型塞进UE项目,这篇的经验可以直接搬。
1. 阶段目标拆解:先别急着炫技,把“可用的闭环”跑通
1.1 项目最终要解决什么问题
先摆一摆现实背景。医患沟通能力的训练,在医学教育里一直是个老大难。传统的标准化病人(SP)模式效果很好,但成本高、可重复性差、病例覆盖有限,遇到疫情等原因还经常中断。而纯粹用预设对话树做的模拟系统,学生只要记住几个固定选项就能蒙混过关,练不出真实对话中的临场感和情绪处理能力。
我们希望做出来的系统,是一个“能练、能评、能复盘”的闭环。学生进入一个虚拟诊室,面对一个由LLM扮演的、有具体症状和情绪状态的患者,用自然语言自由问诊。系统在后台记录整段对话,从信息采集完整性、共情表达、问诊结构、信息告知等多个维度给出评价。训练结束后,学生能看到自己的得分和具体的改进话术,老师也能看到班级整体的能力画像。
到了第二阶段,技术任务细化下来就是三件事:把大模型接到UE项目里并保证稳定对话、让UE端患者的情绪和反馈跟上对话内容、把评价逻辑做成一套能落地的规则引擎加LLM分析服务。这三件事互为支撑,谁拖后腿整个演示都会崩。
1.2 为什么技术栈偏偏是UE加LLM
选型的过程我们其实推翻过好几版。最初有人提议直接做Web版,用网页加聊天框实现,开发快、部署方便。但这类沟通训练对“临场感”的要求很高,学生需要看到患者的表情、坐姿、情绪变化,甚至后续我们会加入VR头显版本,UE在这一层的优势是其他方案很难替代的。再加上项目组希望在场景里实现可交互的3D检查设备、体征展示面板,UE的实时渲染和蓝图快速迭代能力正好能承接这些内容。
LLM替代传统对话树的原因更直接。预设分支的用户体验是断裂的,学生一旦换了说法,系统就答非所问,训练就变成了猜答案。LLM可以在角色设定约束下自由生成回答,学生无论用口语化、书面化甚至方言化的表达,患者都能自然回应。把LLM当作患者的大脑,把UE当作患者的身体,这个组合就成了整套系统的技术底座。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 患者角色的灵魂:从角色卡到Prompt工程
2.1 患者角色卡设计:信息结构化才能让LLM不发散
LLM默认什么都知道,但我们要的不是一个“全知患者”,而是一个只知道自身病情、对医学知识一知半解的普通人。所以第一步就是做角色卡,把患者的个人信息、主诉、现病史、既往史、性格特征、情绪状态全部结构化。
我们的角色卡放在服务器端的JSON里,字段包括:name、age、job、chiefComplaint、presentIllness、pastHistory、personality、currentEmotion、communicationStyle、knowledgeBoundary。其中knowledgeBoundary特别重要,它明确告诉模型哪些检查结果患者自己也不知道、哪些医学名词患者听不懂,防止模型“开天眼”把不该说的信息直接抛出来。
把病例拆成数据而不是写死在Prompt里,是为了让指导老师也能配置病例。老师不熟悉代码,我们就做了一套简单的病例编辑界面,输出固定JSON结构,系统启动时由UE请求服务端拉取病例列表。
在每轮对话开始前,系统会把角色卡转成一段system prompt,大致结构是这样的:
text复制你是一名前来内科门诊就诊的患者,你的名字是张桂芳,55岁,退休工人。
主诉:反复上腹部疼痛3个月,加重1周。
现病史:(详细病史内容)
既往史:高血压5年,规律服药。
性格特征:性格要强,不愿意承认自己焦虑,对年轻医生有些挑剔。
当前情绪:因疼痛加重而担心,但有掩饰倾向。
知识边界:你听不懂医学术语,不清楚胃镜的具体操作,没做过相关检查。
行为规则:除非医生明确问到并解释必要性,否则你不会主动提出做胃镜。
应答风格:口语化,不要像AI,回答长度控制在两三句以内。
这套指定的核心是“克制”。角色卡不是越详细越好,而是要把患者实际知道什么、不知道什么、愿意说什么、不太愿意说什么定清楚。否则模型很容易把患者演成什么都懂的医学顾问。
2.2 上下文管理和对话状态:让患者有“记性”但不“超纲”
对话系统不能每轮单独调用模型,患者需要有短时记忆。我们采用的做法是维护一个message数组,每次请求把最近N轮对话发给LLM服务端。第一版直接把全部历史记录带上,结果到第20轮左右,模型开始“意识到自己是在被AI训练”,回答越来越像助教。排查后发现自己犯了典型的上下文污染问题,患者角色被系统提示之外的对话历史带偏了。
解决方案有两步。第一步,限制上下文轮数,最近12轮以内的对话保留完整内容,更早的内容由服务端做一次摘要压缩,只保留已经提到的关键诊断线索和患者的情绪变化。第二步,在每次调用之前,把患者当前情绪状态作为元信息重新注入,例如在最新一条user消息前附加一段:当前情绪状态:焦虑程度6/10,防御性较高,刚刚提到家庭矛盾后短暂沉默。请基于这个状态继续回答。
多轮处理的流程简单来说就是:前端把用户说的话发给会话服务,会话服务先判断是否需要触发事件(比如患者情绪阈值、关键信息收集节点),然后拼接消息列表调用LLM,拿到回复后返回给UE端并更新会话记录。我们把这层会话逻辑单独做成服务,不放在UE蓝图里,一方面是为了后续能供Web端复用,另一方面是C++里的字符串和JSON处理远不如服务端方便。
2.3 用流式还是非流式:体验与稳定的平衡
LLM推理速度大概在每秒20到40个token,一段30个中文字的回复要3到6秒才能完整生成。如果不做流式输出,学生点了发送按钮后面对一个静止画面干等,体验非常糟糕。我们的解决思路是UE端建立WebSocket长连接,服务端收到生成请求后按token流推送文本片段,UE端每收到一片就追加到字幕框并调用TTS生成一小段语音。
流式落地要注意一个坑:如果逐token触发TTS,会生成大量碎片音频,播放层根本来不及调度。我们处理的方式是文本侧边收边显示,语音侧则在积累到完整句子后再触发一次TTS。也就是UI是流式的,语音是准实时的。实测下来,流式的体感延迟比非流式降低一半以上,学生不会觉得对方卡住了。
当然,非流式的HTTP调用仍然保留,用在“评价请求”这种不需要实时反馈的场景。
3. UE端的表现力:让数字患者活起来
3.1 场景与人物的搭建路线
模型资源方面,我们用MetaHuman做患者身体,因为它的面部绑定质量高,嘴唇同步有原生支持。场景用的是商城里的复古诊所包再改布局。第一阶段我们搭的是固定机位加对话面板,学生不能自由走动,只能在桌前与患者对坐。第二阶段的场景改进是把操作方式改成第一人称坐姿,所有交互都在桌面范围内完成,鼠标点击桌面上的病历本、检查单可以查看信息。这个改动看起来不大,但对于营造“在诊室值班”的感觉很重要。
有个经验值得单独说:不要在场景里堆太多互动物品。我们的初版场景放了血压计、听诊器、体温仪、可翻页的病历架,结果学生的注意力全在乱点物品上,问诊节奏被切得稀碎。后来把大部分物品改成只读展示,只保留与当前训练病例相关的交互,教学效果反而提升。训练系统最重要的目标是沟通练习,不是探索解谜。
3.2 情绪表现:让状态驱动动画,而不是动画驱动状态
患者要显得真实,全靠人工编排动画是不现实的。我们做了一套状态驱动的表现系统,服务端返回对话时会附带一个每位患者的情绪标签,例如anxious、defensive、calm、angry,UE端根据标签查找对应的动画蒙太奇和表情序列并播放。
情绪标签的生成方式是在LLM返回患者回复的同时,要求模型输出一个JSON对象,包括reply、emotion、vitalSignsChanged三个字段。reply是回复文本,emotion是当前情绪,vitalSignsChanged表示患者血压心率是否有波动。这个输出的JSON被服务端解析后再分开推送给UE的动画系统和UI系统。
实现细节上,蓝图中有一个PatientEmotionHandler,监听对话事件,根据emotion值调用不同蒙太奇。比如anxious状态会让患者身体前倾、手指交缠、语速变快;defensive会让患者双臂交叉、眼神回避。这些动画并不复杂,但配合对话内容出现的时间点对了,学生的感受会完全不一样。重点不是动画多华丽,而是触发时机要对。
3.3 文本输入与语音输入的取舍
项目原计划是让学生用麦克风直接对话,更接近真实诊室。但第一轮测试后我们发现语音识别带来的变量太多:学生口音、环境噪音、收音设备质量都会影响对话成功率。一旦识别出错,LLM接到的就是狗屁不通的文本,学生还要对着系统解释,反而浪费时间。
所以这一阶段我们改成了双通道:默认在主界面展示一个文本输入框,学生打字提问,适合安静环境;另提供“按住说话”按钮,适合后续在有降噪耳机的环境下使用。语音通道用云厂商的ASR服务,识别结果进入同一条文本链路。文本为主、语音为辅的方案,保证了训练主流程不因为技术噪音而中断。后续如果有更好用的端侧语音识别方案,再考虑切换。
3.4 数据传输对象的设计:蓝图与C++的分工
系统里有大量需要跨模块传递的数据,比如患者信息、对话历史、评分报告。如果全部用蓝图的结构体散装传输,后期维护会痛不欲生。我们从一开始就规定:数据模型全部用C++定义USTRUCT,蓝图只负责展示和流程控制,不负责解析复杂JSON。
对话消息的USTRUCT大致是这样的:
cpp复制USTRUCT(BlueprintType)
struct FMessageData
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly)
FString Role; // patient or student
UPROPERTY(BlueprintReadOnly)
FString Content;
UPROPERTY(BlueprintReadOnly)
FString EmotionTag;
UPROPERTY(BlueprintReadOnly)
FString CreatedAt;
};
所有从WebSocket收到的JSON先经由C++转成TArray<FMessageData>再抛给蓝图绑定的事件。这样做的好处是,蓝图里再也看不到一层套一层的GetField节点,逻辑看起来清爽。给后面接手项目的学弟学妹也省了不少沟通成本。
4. 结构化评价系统:从“打一个分”到“一段可解释的反馈”
4.1 评价维度怎么定:参考SEGUE但不照搬
医患沟通能力评价的经典工具是SEGUE框架,分为“准备阶段、信息收集、信息给予、理解患者、结束问诊”五个维度。直接照搬这个量表到自动评价系统会遇到一个问题:量表里的很多观察项依赖非语言行为,比如“医生是否保持眼神接触”“是否使用了鼓励性肢体语言”。现阶段系统还录不到眼神接触数据,硬套上去,评价就失真了。
我们实际采用的是SEGUE框架加德尔菲法调整后的维度,每个维度换算成可计算的打分点。举例来说,“信息收集”维度的评价点包括:是否询问了疼痛的部位、性质、诱因、持续时间、加重缓解因素;是否询问了伴随症状;是否询问了既往病史和用药史。这些信息点可以从对话文本中直接判断,适合自动化处理。
“共情与情绪支持”维度,评价点包括:是否回应了患者的情绪表达、是否使用安慰性语言、是否解释了下一步检查的目的。这类判断需要结合语义,单靠关键词匹配会误判,所以由LLM来做辅助分析。
4.2 客观评分与LLM评分双轨并行
我们做过三次对比试验,纯规则评价的准确率大概在60%左右,误报集中在学生换了一种表达方式但意思正确的情况;纯LLM评价的准确率在80%以上,但输出不稳定,同一段对话跑两次可能得到不同分项得分。最终方案是客观为主、LLM为辅。
客观规则负责“铁板钉钉”的指标,例如:是否提到“疼痛持续多久”、是否包含“高血压病史”等,这些用关键词和简单语义匹配就能确认。LLM负责主观语义指标,比如“医生是否对患者的担忧表达了理解”“患者情绪是否出现由负转正的变化”。LLM评分节点要求它输出每个维度的得分、理由以及对应的原文摘录。
评分的一致性在这里非常关键。我们固定使用一个独立的评分模型来跑这部分,把temperature参数设为0,并且对同一段对话重复推理三次,取多数结果作为最终输出。调试时发现偶尔仍然出现情绪类指标过山车式的抖动,后来定位到是上下文长度不一致引起的,于是我们把输入评分器的文本固定为“最近的完整对话轮次加服务端摘要”,避免系统只凭最后几轮对话就下结论。
4.3 评价报告的实际样式
评价结果不是简单给个总分。系统会生成一份评价报告,分为四块:总分与雷达图、分维度明细、优秀表现摘录、改进建议与示范话术。雷达图在UE里用UMG画虽然麻烦一点但也做成了,主要用的是一个自绘的Canvas控件。
改进建议部分我们踩过一个坑。最初生成的建议很泛,比如“请提高共情能力”“注意信息收集完整性”,这种话说和没说一样。后来我们要求LLM必须结合对话原文给建议,例如:学生在患者提到“最近睡不好,总担心是癌”时直接跳到下一个问题,建议栏就会提示:“患者表达了疾病担忧,建议在此时使用共情句式‘听您这么说,我能理解您的担心’,再追问担忧的具体原因。”这种建议学生复盘中看到了才会觉得有价值,也会愿意改。
4.4 训练模式与考核模式,评价节奏完全不同
随着评价模块的出现,系统分裂成两种使用模式。训练模式下,系统鼓励过程中探索,不打断学生对话,直到学生主动结束或达到最大轮数;结束后进入复盘。考核模式下,系统约定了15分钟倒计时,并且如果学生问诊方向明显跑偏,患者会表现出不耐烦或请求结束,从而制造压力环境。
考核模式对LLM角色的稳定度要求更高,我们需要限制患者回答的随机性。同一份病例,不同学生面对的患者,核心信息框架必须一致。这意味着即使学生的问法不一样,患者透露关键信息的“门槛”也要保持一致。实现方式是在病例表里固化了一个mustOverrideThreshold,当对话覆盖到关键触发词时强制在下一轮回复中透露对应信息,而不是依赖LLM心情。这也是我们从“用AI讲故事”到“用AI做教学”的重要转变。
5. 中期开发踩坑记录
5.1 UE端的常见问题速查表
这段时间遇到的问题不少,挑几个典型的整理在下面,其他人遇到同类的可以直接对照排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| UE请求本地服务连接失败 | 打包后网络权限未开或地址写死成localhost | UE项目设置里勾选HTTP、WebSocket支持,地址按环境变量读取 |
| 对话里出现“抱歉,我作为AI语言模型无法...” | system prompt未约束或上下文被对话历史污染 | 检查角色卡完整性,限制历史轮数,追加“禁止提及自己是AI”规则 |
| 患者回答出现超纲信息 | role卡的知识边界不明确 | 在system prompt中强调“你只知道自己被提供的信息,未知项回答不清楚” |
| 长时间对话后患者性格漂移 | 早期摘要压缩丢掉了性格锚点 | 把角色卡的前缀消息固定放第一轮,并且不参与滑动窗口截断 |
| 同一句话评价分次结果不同 | 评分模型temperature过高或输入长度不一致 | 评分调用temperature设为0,重复推理三次取多数 |
| 中文对话偶尔出现乱码 | 编码声明缺失,HTTP库默认按ASCII解析 | 请求头显式指定Content-Type: application/json; charset=utf-8 |
| 语音演示时卡顿 | TTS按片段切得太碎 | 按完整句子触发TTS并加入播放队列 |
| 学生乱点物品导致问诊中断 | 场景交互对象过多 | 精简交互对象,仅保留与病例相关的关键物件 |
5.2 大模型幻觉是医患模拟的硬伤,怎么控
大模型幻觉在这个场景里不能容忍。会出现两种情况:第一种,患者随口说出一个自己从未做过的检查结果,比如“我的胃镜显示有糜烂”,这对训练结论是致命误导。第二种,学生问到一个患者不知道的问题,模型为了回答流畅而编造病史,导致信息采集评价失真。
针对第一种情况,我们在prompt里反复强调“你只知道已经提供给你的病史资料,任何检查结果除非在病例中明确出现,否则你应该说没查过或不知道”,并在病例里预置了检查结果知情标志位。针对第二种情况,我们在服务端做了一层响应过滤,把患者回复中的疑似新生医学事实与病例库比对,若发现病例中没有来源的诊断或检查结论,就自动打回让模型重写一次。
这不是完全杜绝幻觉的终极方案,但在工程层面能显著降低错误率。最终我们做了一次50轮模拟对话测试,涉及12个病例,过滤前出现幻觉类错误17次,过滤后降到3次。
5.3 不要让LLM实时互动拖垮整个项目进度
一个容易被忽略的坑是依赖LLM API的在线调试效率极低。每次改完UE蓝图,跑一次完整流程,高峰期请求要等好久,一天下来根本迭代不了几次。我们后来把所有对话场景都做成了可录制的回放模式:先把一整段学生对话录成JSON,调试时通过命令行参数指定跑这个回放文件,不实际调用LLM,UE侧照样走完整流程,这样调试体验和效率大幅提升。
另一个习惯是固定录几条高覆盖率的测试对话集,每改动一个系统模块就全量回归一遍。因为LLM的输出有随机性,人工测试经常要试好几轮才能重现问题,回放测试集让回归变得可预期。
6. 走出演示:把“能跑”变成“稳跑”的几点体会
做到这个阶段,最大的体会是:把LLM接进UE并让患者开口说话只是开始,真正难的部分是让整个系统稳定地服务于教学场景。实训项目的最终考核要看系统能不能连续运行、数据可不可追踪、评价能不能服众。模型抽风、角色漂移、评价抖动,这些不稳定性会直接毁掉一套看起来很有前景的产品原型。
如果是想复现类似项目的朋友,我建议按这个顺序推进:先把病例配置和对话链路做稳定,再把评价维度定义清楚,最后才去打磨角色动画和配音。过早做动画演示会占用大量时间,但对话逻辑没通,再好的动画也只是空壳。技术上优先选WebSocket长连接方案,评分模型和对话模型拆开跑,病例配置全部外置为数据文件,这样你后面改病例、调评分规则都只需要改服务端配置。
下一步我们计划做三块:一是把评价结果导出成符合教学管理要求的Excel和PDF报告,让老师能批量查看班级数据;二是加入更多基于肢体语言的信号采集,比如通过摄像头识别学生的眼神方向和手势;三是把病例库扩充到呼吸内科、心内科、肿瘤科三个科室,形成按科室分类的训练单元。实训项目的验收节点就快到了,这个系统从最初一个会说话的虚拟人到今天能评能练的完整训练工具,总算有了能站住脚的雏形。
