Go 语言构建Ambient AI 病历自动生成系统研讨(下)

Go 语言构建Ambient AI 病历自动生成系统研讨(下)

四、门诊场景流程:端到端流程(Go 视角)

我们将详细描述一次完整的门诊,数据和 Go 服务是如何流动的。

Step 1: 医生准备,发起会话

医生操作:在医生工作站打开患者的就诊页面,点击“开始智能记录”。前端动作:携带本地存储的
doctor_token
,调用
POST /api/v1/sessions

gateway-service

校验
doctor_token
的有效性。通过
doctor_id

Casbin
策略中检查,该医生是否有操作
patient_id
的权限。请求转发至
session-service


session-service
(gRPC)

接收
CreateSessionRequest
(包含
patient_id
,
dept_id
,
outpatient
类型)。在 PostgreSQL 中创建一条
Session
记录,状态为
ACTIVE
。生成一个全局唯一的
session_id
(e.g., UUID)。构建录音文件的未来存储路径,如
recordings/2023/10/27/{session_id}.webm
。返回
CreateSessionResponse
,包含
session_id
和为本次连接分配的 WebSocket URL。
前端:收到响应后,使用
session_id
建立到
wss://gateway/asr/{session_id}
的 WebSocket 连接。

Step 2: 问诊进行中,实时转写

音频采集:浏览器通过
getUserMedia
API 获取麦克风音频流,编码为 Opus 或 PCM 格式,通过 WebSocket 实时发送。
gateway-service
:仅做 WebSocket 连接的升级和负载均衡,将 TCP 连接代理到后端的
asr-adapter-service
实例。
asr-adapter-service
(核心 Goroutine 逻辑)

连接管理:为每个 WebSocket 连接创建一个 Goroutine。音频转发:循环读取 WebSocket 音频帧,打包成 ASR 引擎(如阿里云)API 要求的格式,通过 HTTP Streaming 或 gRPC Stream 发送。结果接收与处理
异步接收 ASR 引擎返回的
partial_result
(部分转写)和
final_result
(最终结果)。
partial_result
:立即通过 WebSocket 推回前端,实现“实时字幕”效果。
final_result
:这是一个完整的句子或段落。它会附带时间戳信息(相对于音频开始的时间)和可能的说话人标签(如果 ASR 支持声纹分离)。
asr-adapter-service
将其封装成一个
TranscriptionEvent
消息,发布到 NATS 的
transcription.finished
主题。


session-service
(NATS Consumer)

订阅
transcription.finished
主题。消费消息,解析出
session_id

final_result
。将这条转写记录作为一条
Turn
插入到数据库的
transcriptions
表中,并与
session_id
关联。

Step 3: 问诊结束,触发 AI 生成

医生操作:点击“结束记录”按钮。前端动作:发送一个 WebSocket Close 消帧,并调用
POST /api/v1/sessions/{sessionId}/end

session-service


Session
记录的状态更新为
ENDED
,并记录
end_time
。向 NATS 的
session.ended
主题发布一条消息,包含
session_id


nlp-medical-service
(NATS Consumer)

订阅
session.ended
主题。收到消息后,开始执行核心的病历生成流程。
数据拉取:调用
session-service
的 gRPC 接口
GetSessionWithTranscriptions(sessionId)
,获取完整的会话信息和所有转写文本
Turn[]
构建 Prompt:将
Turn[]
拼接成对话字符串,并填充预设的 Prompt 模板。调用 LLM:使用
llmClient.GenerateEMR()
发送请求到 LLM 服务。这里需要设置一个合理的超时时间(e.g., 30s)。结果处理
成功:解析返回的 JSON,进行格式和业务校验。将生成的
EMRDraft
(JSON格式)存入数据库的
emr_drafts
表,关联
session_id
。失败(网络错误、LLM返回错误、JSON解析失败):记录错误日志,将
session
状态标记为
PROCESSING_FAILED
,并通知医生前端“AI 生成失败,请手动录入”。
状态更新:成功后,将
session
状态更新为
COMPLETED

Step 4: 医生审核与编辑

前端:通过轮询或 WebSocket 接收,获取到“草稿已生成”的通知。前端:自动从
nlp-medical-service
拉取
EMRDraft
JSON。前端渲染:将 JSON 数据渲染到可编辑的表单中。每个字段(主诉、现病史等)都是独立的输入框或富文本编辑器。医生可以对比原始对话录音和转写文本,进行修改。医生操作:修改完毕后,点击“提交”按钮。

Step 5: 写入 EMR,完成闭环

前端动作:调用
POST /api/v1/emr-drafts/{draftId}/submit
,携带最终修改后的病历 JSON。
gateway-service
->
emr-integration-service


emr-integration-service
接收到提交请求,验证
doctor_id
是否有权限提交此
draft_id
。将此写入操作创建成一个异步任务(如使用
Asynq
),任务中包含
session_id
,
final_emr_json
。立即返回给前端“提交成功,正在同步至 EMR”。

emr-integration-service
(Task Worker)

后台 Worker 进程从队列中取出任务。根据
session_id
关联出的
hospital_id
,找到对应的
EMRWriter
实现。调用
writer.Write()
方法,执行实际的 API 调用和数据转换。成功:更新
emr_drafts
表中记录的状态为
SYNCED_SUCCESS
,并记录
sync_time

emr_response
失败:更新状态为
SYNCED_FAILED
,并记录错误原因。
Asynq
会根据配置(如指数退避策略)自动重试。重试多次后失败,则进入“死信队列”,需要人工介入。

audit-log-service
:全程记录所有关键操作的审计日志,如
Doctor(A) created session(S) for Patient(B)
,
EMR draft for session(S) was generated by AI
,
EMR for session(S) was synced to VendorA EMR successfully


五、病房场景流程:重点在“持续记录 + 多人参与”

病房场景的复杂性在于时间拉长和角色变多。我们的 Go 服务架构需要能够适应这种变化。

与门诊的核心差异

会话模型
门诊:短会话,一次问诊一个
session
病房:长会话。一个住院病人的整个住院期间可以被视为一个大的
inpatient_session
,而每天的查房、每次的护理操作则是这个大
session
下的
sub_session

event

数据粒度
门诊:以“对话”为中心,最终生成一份总结性文档。病房:以“事件”为中心。需要从连续的对话中识别出离散的关键医疗事件,如“体温 38.5℃”、“咳嗽加剧”、“医嘱:XXX 药物剂量调整为…”、“护理操作:换药”。
输出文档
门诊:门诊病历。病房:入院记录、首次病程记录、日常病程记录、上级医师查房记录、护理记录、手术记录、出院小结等。不同事件对应不同模板。

病房版 Go 服务适配


session-service
扩展

数据模型需要支持
session_type: 'INPATIENT_STAY'

session_type: 'WARD_ROUND'

Session
表增加
admission_id
(住院号)字段。需要一个新的
Event
表,记录结构化的事件:


type Event struct {
    EventID       string    `gorm:"primaryKey"`
    SessionID     string    `gorm:"index"` // 关联到住院大session
    EventType     string    // "VITAL_SIGNS", "DOCTOR_ORDER", "NURSING_NOTE", "PATIENT_COMPLAINT"
    EventTime     time.Time // 事件发生的时间点
    SpeakerRole   string    // "DOCTOR", "NURSE", "PATIENT"
    RawText       string    // 触发事件的原始对话
    StructuredData datatypes.JSON // 结构化数据,如 {"vitals": {"temp": 38.5, "unit": "℃"}}
}


asr-adapter-service
增强

需要支持多通道音频输入(如果硬件支持),或更依赖 NLP 进行说话人分离。说话人标签需要更丰富:
D01
(张医生)、
N03
(李护士)等,这可以在登录时绑定。

病房版详细流程

Step 1: 创建住院会话

患者入院时,HIS 系统通过 webhook 或我们主动拉取,触发
session-service
创建一个
Session

type

INPATIENT_STAY

status

ACTIVE

Step 2: 日常事件捕获

查房:医生团队推着移动查房车进入病房。一名医生在 App 上“开始查房记录”,这会创建一个临时的
WARD_ROUND
子会话,但所有数据都归属到大
INPATIENT_STAY
会话下。护理操作:护士在床旁完成操作后,可能只需说一句“记录”,系统自动将前 1-2 分钟的对话进行识别。实时处理
asr-adapter-service

nlp-medical-service
可以实现更紧密的“流式”处理。

nlp-medical-service
不再等会话结束,而是订阅
transcription.final
流。每来一段
final_result
,立即调用一个轻量级的 NLP 模型(可能是本地部署的 Python 模型,通过 gRPC 调用)进行意图识别实体抽取意图识别:判断这段话是陈述病情、是下医嘱,还是日常闲聊。实体抽取:抽取出关键信息,如
体温: 38.5℃
,
药物: 阿司匹林
,
剂量: 100mg

nlp-medical-service
将这些结构化的事件写入
Event
表。

Step 3: 自动生成/更新病历

触发机制
定时任务(如每天凌晨 2 点)。医生主动点击“生成今日病程”。

nlp-medical-service
的工作

拉取指定
patient_id
在 24 小时内所有的
Event
记录。拉取这期间所有的对话
Turn[]
。构建一个更复杂的 Prompt,任务是根据“事件列表”和“对话原文”,生成“日常病程记录”草稿。Prompt 会指明需要整合哪些类型的事件(如生命体征变化、新出现的症状、医嘱调整等)。调用 LLM 生成草稿。生成的草稿会与之前的病程记录进行“逻辑串联”。例如,如果昨天记录“患者体温 38.0℃”,今天新事件是“体温 37.2℃”,LLM 可能会生成“经抗感染治疗,患者体温降至正常。”。

Step 4: 多角色协同审阅

生成的护理记录草稿推送给护士工作站,病程记录推送给医生工作站。系统记录每个角色的审阅和修改痕迹。这对于后续的模型迭代和责任划分至关重要。


六、核心难点 & Go 侧应对思路

1. 医疗场景的“三高”:高安全、高可靠、高可用

挑战:病历是法律文书,任何丢失、篡改、服务中断都是严重事故。Go 侧应对
高安全
全链路加密:所有对外服务强制使用
TLS 1.3
。内部 gRPC 也启用 mTLS。最小权限原则
Casbin
的策略要做到精细到资源实例级别。
doctor, doc_A, patient, p_B, write
敏感数据脱敏:日志输出时,对姓名、身份证号等进行脱敏处理。可自定义
zap

Encoder
来实现。
高可靠
事务性:涉及多个数据库操作的核心逻辑(如结束会话并触发事件),必须使用数据库事务。幂等性:所有接收消息的接口(NATS Consumer、HTTP Callback)必须是幂等的。通过唯一 ID 去重,防止重复处理。分布式事务(Saga 模式):对于跨服务的操作(如生成病历 -> 写入 EMR),使用 Saga 模式。
emr-integration-service
写入失败后,需要通过补偿事务来标记
nlp-medical-service
中的草稿为“同步失败”,而不是让数据不一致。
高可用
无状态服务:所有 Go 服务都设计为无状态,可以任意水平扩缩容。状态存储在 Redis 或 DB 中。健康检查:每个服务都实现
/healthz

/readyz
接口,供 K8s 进行健康检查和流量切换。优雅停机:监听
SIGTERM
信号,在收到信号后,停止接收新请求,等待现有请求处理完成,再退出。Go 的
http.Server

grpc.Server
都支持优雅停机。

2. 延迟控制

挑战:医生无法忍受“说了半天,字幕才出来”或“等半天病历出不来”。Go 侧应对
实时字幕延迟 (< 2s)
关键在于
asr-adapter-service
和 ASR 引擎之间的网络延迟。Go 服务应部署在与 ASR 引擎网络最近的位置。使用
gRPC Streaming
或 WebSocket Stream,避免 HTTP 1.1 的握手开销。
病历生成延迟 (< 30s)
主要瓶颈在 LLM 调用。Go 侧能做的是优化 Prompt,减少 LLM 处理时间,以及做好并发控制。使用
context.WithTimeout
为每次 LLM 调用设置超时,防止长时间等待。对于高并发场景,使用
semaphore.Weighted
控制对 LLM 的并发请求数量,避免压垮 LLM 服务。

3. 方言、噪声与口语化

挑战:ASR 和 NLP 模型的天然难点。Go 侧应对
ASR 侧辅助:在调用 ASR API 前,
asr-adapter-service
可以动态加载“科室/医生常用词表”(存于 Redis 或配置中心),作为请求参数发给 ASR 引擎,提高专业词汇识别率。NLP 后处理
nlp-medical-service
在拿到 LLM 返回的草稿后,可以执行一个“规则修正”模块。用 Go 实现:
同义词/标准化字典
map[string]string{"感冒": "上呼吸道感染", "降压药": "抗高血压药物"}
单位修正:将“八十”修正为“80”,“三十八度五”修正为“38.5℃”。敏感词过滤:对模型可能生成的离谱内容进行兜底检查。
持续学习闭环

audit-log-service
记录医生对 AI 草稿的每一次修改。定期跑批处理脚本(Go 写),分析这些修改数据,统计高频错误类型。将分析结果(如“模型经常把‘胸痛’识别为‘心痛’”)提供给算法团队,用于模型优化和 Prompt 调优。

4. 隐私合规

挑战:患者数据是最高级别的隐私,受法律严格保护。Go 侧应对
数据分类分级:在数据库设计层面,对不同敏感度的数据(姓名 vs. 主诉)打上标签。数据访问审计
audit-log-service
的核心职责。每一次对
patient_id
相关数据的查询和修改,都必须记录
who, when, where, what
数据生命周期管理:制定数据保留策略。例如,录音文件在病历确认并归档后 30 天自动删除(需满足法规要求)。这可以用 Go 写一个 Cron Job 来实现,通过对象存储的 API 删除过期文件。最小化数据采集:只采集业务必需的数据。避免在元数据中存储不必要的患者个人信息。部署隔离:开发和测试环境严禁使用生产环境的真实患者数据。必须使用脱敏或生成的假数据。Go 的配置管理 (
Viper
) 可以方便地切换不同数据源。


七、如果要开干:一个最小可行版本(MVP)建议

您提的 MVP 思路非常正确。大而全的项目容易失败,小而精的切口才能快速验证价值。

MVP 目标:

验证核心价值:AI 自动生成的病历草稿,能否真正帮助医生节约时间、提高效率?跑通技术链路:从音频采集到病历生成,全流程是否通畅?收集真实反馈:医生对草稿质量的评价,是后续优化的金矿。

MVP 范围与技术栈:

场景:仅限一个试点科室(如 呼吸内科门诊)。功能
✅ 医生在 Web 端点击“开始/结束录音”。✅ 实时语音转文字(使用第三方云服务,如阿里云 ASR)。✅ 录音结束后,同步调用 LLM(如 OpenAI API)生成病历草稿。✅ 医生在 Web 端修改草稿,并本地保存(不写 EMR)。✅ 提供修改前后的对比界面,用于评估效果。
服务拆分(极简版)

gateway-service
,
session-service
,
nlp-medical-service
合并为一个单体 Go 应用 (
monolith-service
)。
asr-adapter-service
保留,因为它处理流式,逻辑相对独立。
emr-integration-service

audit-log-service
暂不实现。EMR 集成替换为文件存储,审计日志暂用文件日志。
数据存储
数据库
PostgreSQL
对象存储
MinIO
(本地部署,简单易行)。录音存储:直接存 MinIO。病历草稿:存 PostgreSQL 的 JSONB 字段。
部署
使用
Docker Compose
在一台服务器上启动所有服务(
monolith-service
,
asr-adapter
, PostgreSQL, MinIO, 前端 Nginx)。

MVP 项目结构示例:

/ai-emr-mvp
├── /backend                 # Go 后端
│   ├── cmd/
│   │   ├── monolith/main.go # 主应用入口
│   │   └── asr-adapter/main.go # ASR 适配器入口
│   ├── internal/
│   │   ├── asr/             # ASR 适配器逻辑
│   │   ├── config/          # Viper 配置加载
│   │   ├── handler/         # HTTP handlers (REST & WS)
│   │   ├── model/           # GORM 数据模型
│   │   ├── llm/             # LLM 客户端
│   │   ├── repository/      # 数据库操作抽象层
│   │   └── service/         # 业务逻辑层
│   ├── pkg/                 # 公共包
│   └── go.mod
├── /frontend                # React/Vue 前端
├── /deploy                  # Docker Compose 部署文件
│   └── docker-compose.yml
└── README.md
MVP 的 Go
Session
模型示例:

package model

import (
    "time"
    "database/sql/driver"
    "encoding/json"
    "errors"

    "gorm.io/gorm"
)

// Session 存储一次门诊问诊会话
type Session struct {
    ID             string         `gorm:"primaryKey;type:varchar(255)" json:"id"`
    DoctorID       string         `gorm:"not null;index" json:"doctorId"`
    PatientID      string         `gorm:"not null;index" json:"patientId"`
    DepartmentID   string         `gorm:"not null" json:"departmentId"`
    Status         SessionStatus  `gorm:"type:varchar(50)" json:"status"`
    RecordingURL   sql.NullString `gorm:"type:varchar(512)" json:"recordingUrl,omitempty"`
    Transcript     Transcripts    `gorm:"type:jsonb" json:"transcript,omitempty"`
    EMRDraft       sql.NullString `gorm:"type:jsonb" json:"emrDraft,omitempty"` // 存放 AI 生成的草稿
    DoctorModified EMRDraft       `gorm:"type:jsonb" json:"doctorModified,omitempty"` // 存放医生最终版
    StartedAt      time.Time      `gorm:"" json:"startedAt"`
    EndedAt        *time.Time     `gorm:"" json:"endedAt,omitempty"`
    CreatedAt      time.Time      `gorm:"" json:"createdAt"`
    UpdatedAt      time.Time      `gorm:"" json:"updatedAt"`
    DeletedAt      gorm.DeletedAt `gorm:"index" json:"-"`
}

type SessionStatus string

const (
    SessionStatusActive    SessionStatus = "ACTIVE"
    SessionStatusEnded     SessionStatus = "ENDED"
    SessionStatusCompleted SessionStatus = "COMPLETED"
    SessionStatusFailed    SessionStatus = "FAILED"
)

// Transcripts 存储对话轮次
type Transcripts []Turn

type Turn struct {
    Speaker  string    `json:"speaker"` // "doctor" or "patient"
    Text     string    `json:"text"`
    StartMs  int       `json:"startMs"` // 相对于音频开始的毫秒数
    EndMs    int       `json:"endMs"`
    IsFinal  bool      `json:"isFinal"`
}

// EMRDraft 结构化病历
type EMRDraft struct {
    ChiefComplaint           string   `json:"chiefComplaint"`
    HistoryOfPresentIllness  string   `json:"historyOfPresentIllness"`
    PastHistory              string   `json:"pastHistory"`
    // ... 其他字段
}

// 实现 GORM 的 Scanner 和 Valuer 接口,用于自定义类型的序列化
func (t *Transcripts) Scan(value interface{}) error {
    if value == nil {
        *t = Transcripts{}
        return nil
    }
    bytes, ok := value.([]byte)
    if !ok {
        return errors.New("cannot scan non-bytes value into Transcripts")
    }
    return json.Unmarshal(bytes, t)
}

func (t Transcripts) Value() (driver.Value, error) {
    if t == nil {
        return nil, nil
    }
    return json.Marshal(t)
}

这个 MVP 架构虽然简单,但它包含了核心的数据模型、服务边界和技术选型。当医生用起来并给出“真香”反馈后,我们就可以基于这个坚实的内核,逐步拆分微服务、接入 EMR、扩展到病房场景了。


总结

通过以上详尽的分析,我们可以看到,使用 Go 语言来构建「Ambient AI 病历自动生成系统」的核心业务层,不仅技术上可行,而且在性能、稳定性、可维护性方面具有显著优势。Go 不是全能的,它不负责 AI 模型的“灵魂”,但它能构建出承载这个灵魂的强大而可靠的“身体”。

接下来的路,就是从 MVP 开始,让代码在真实的业务场景中运行起来,不断迭代,用 Go 代码一行一行地敲出医疗信息化的未来。

© 版权声明

相关文章

暂无评论

none
暂无评论...