事情是从三月份开始的。我们信息科在一周内连续收到三个科室的需求:内分泌科想要一个能自动解读糖化血红蛋白检验结果的小工具,病案室想把出院记录里的结构化字段从人工录入变成机器辅助,随访中心则希望批量生成术后患者的复诊提醒话术。三个需求单独看都不算大,但放在一起就暴露了医疗信息化里一个很典型的问题:每个业务都想得很清楚,却没有一个统一的能力底座。
我们的解决办法,是用 LangChain 做了一个多 Skill 组合的编排层。所谓多 Skill,就是先把医院里这些高频业务拆成一个个可以独立测试、独立升级的能力单元,再让同一个对话式助手根据用户问题自动组合调用。这篇文章把项目从拆解、实现到上线踩坑的完整过程记录下来,给正在做医疗信息化(尤其是电子病历、检验解读、临床辅助问答这类场景)的团队做个参考。
1. 从科室痛点说起:为什么医疗信息化需要“多 Skill 组合”方案
1.1 三个需求背后的同一个本质问题
内分泌科要的“糖化白蛋白解读”,本质是把一条检验数据变成一句能给患者看的人话;病案室要的“结构化抽取”,本质是从一大段非结构化文本里提取二十几个固定字段;随访中心要的“复诊提醒”,本质是先查出院记录里的关键信息,再套用模板生成话术。这三件事听起来完全不同,但站在信息系统角度看,它们共享同一条链路:数据接入 → 业务理解 → 结构化/生成 → 结果回写。
如果每个科室各找一支团队独立开发,大概率会出现三套重复的数据清洗代码、三个不同的模型调用方式、两套互不兼容的知识库。我们当时还发现一个更麻烦的问题:检验解读和病历抽取都依赖同一个“医学实体标准化”能力,比如说“糖化”和“糖化白蛋白”其实是同一个指标的不同写法,这个映射逻辑如果在三套系统里各写一遍,以后维护就是噩梦。
所以最合理的做法不是做三个功能,而是做一层能承载所有功能的基础能力,然后把各个具体的业务场景挂上去。这层基础能力,我用 LangChain 搭了起来,每个业务场景就变成一个 Skill。
1.2 单模型直连方案的三个瓶颈
在决定做多 Skill 组合之前,我们其实先试过单模型直连的朴素方案:把科室需求和知识背景全部写进一个巨大的系统提示词,让模型直接输出结果。跑了一个月,三个问题非常突出。
第一个是提示词膨胀。三个科室的规则、字段说明、参考范围、模板要求全塞进同一个 prompt 之后,整体长度冲到了几千字,模型开始出现严重的互相干扰:让它做随访话术时,它偶尔会抄一段病历结构化规则进结果,而且越到后面越难排查是哪个片段导致了错误。
第二个是权限边界模糊。所有工具不分层级地混在一个 Agent 里,意味着模型理论上可以同时调用查询检验结果的接口和写随访小结的接口。一次实测中,模型在一个很简单的提问里连续调用了四个无关工具,白白浪费 token,还让日志几乎没法审计。
第三个是验收标准不同。不同科室对质量的衡量方式完全不一样,病案室看字段抽取的完整度,内分泌科看解读内容是否存在方向性错误,随访中心看话术是否合规。混在一个单体实现里,任何一次改动都可能让某个科室的指标倒退。
所以我们需要一种机制,让每个能力可以独立开发、独立评测、独立发布,同时又在用户侧保持一个统一入口。这就是“多 Skill 组合”的核心价值。
1.3 为什么最终选了 LangChain
选型的时候我们对比过自研编排框架和直接用 LangChain。自研的好处是足够定制,但医疗信息化团队通常规模不大,自研一个工具编排层意味着要自己处理工具注册、参数解析、上下文管理、错误重试一堆基础问题,短期成本太高。
LangChain 的价值在于它把“工具”这件事抽象得非常规范:一个函数加一个描述,就能变成一个可以被模型调用的 Skill,框架负责把模型的参数解析结果映射到函数入参。这个约定让我可以把精力集中到“每个 Skill 内部的业务逻辑”上,而不是反复造轮子。
另外,LangChain 的模型接入层是通用的,后续不管换本地部署的开源模型,还是换云端模型服务商,已有的 Skill 代码都不需要大改。对一个医院信息科来说,这种可替换性很重要,因为模型选型大概率会随着成本和效果评估不断调整。
2. Skill 拆解与整体架构:在合规医疗场景里划分能力边界
2.1 我们拆出的六个 Skill 清单
整个架构里最关键的决策不是怎么写代码,而是怎么切 Skill。我们花了两个星期和各个科室反复核对业务边界,最终没有按科室切,而是按职责切,因为不同科室的能力需求是重叠的。
| Skill 名称 | 输入 | 输出 | 主要依赖 |
|---|---|---|---|
| 实体标准化 | 自由文本 | 标准化后的医学实体(含别名映射) | 院内术语字典、规则引擎 |
| 病历结构化 | 分节后的住院记录文本 | 结构化字段 JSON | 大模型 + JSON 解析器 |
| 检验解读 | 指标名、数值、单位 | 初步解读文案(辅助参考) | 检验参考范围配置 + 大模型 |
| 知识库问答 | 用户问题 | 引用来源的回答 | RAG 检索链路 |
| 报告生成 | 结构化病历/随访信息 | 随访小结、健康宣教文案 | 模板系统 + 大模型 |
| 安全合规 | 任意 Skill 的输入输出 | 脱敏文本、拦截标记 | 正则 + 本地 NLP 服务 |
这样做的好处是:每个 Skill 都有一个明确的主责部门。比如检验解读主要服务内分泌科,但将来心血管科也可以用同一个 Skill,只是参考范围配置不同而已。知识库问答的知识来源可以从诊疗指南扩展到处方集,谁都能挂靠,核心逻辑不用重写。
2.2 三个贯穿全局的设计原则
拆完 Skill 之后,我们还定了三条设计原则,后续所有实现都围绕它们展开。
原则一:每个 Skill 必须有清晰的自描述。在 LangChain 里,模型选择工具靠的是函数名和 docstring。我们在每个 Skill 的描述里写清楚“什么时候调用”“需要哪些参数”“什么情况下不要调用”。这一步看似琐碎,但直接决定了模型选工具的准确率。项目上线后我们把描述调优列为常态化工作,每次改动都会跑一遍回归。
原则二:数据访问必须走统一网关。Skill 内部不允许直接连数据库,所有数据获取都通过内部 API 完成。这样做的原因是数据权限和审计可以集中在网关层做,而不是散落在各个 Skill 代码里。比如“查询检验结果”这个动作,网关会记录调用方、时间、患者ID和返回结果,一旦出现越权访问,排查链路是完整的。
原则三:每次推理全链路可追踪。每个 Skill 执行时都会记录一条日志,包含输入、输出、调用模型、耗时、token 数。我们后来定位线上问题时,这套日志救了无数次命。它让我们可以把一次错误回答精确定位到“是哪个 Skill、哪个环节、哪段上下文出了问题”。
2.3 与 HIS/EMR 系统的对接方式
医疗信息化项目绕不开院内的老系统。我们的 HIS、EMR、LIS 都是不同厂商的系统,接口风格不统一,而且有些老库直接连非常危险。所以对接策略我们分了三层。
第一层是视图层:对只读型数据访问,比如查询出院记录、检验结果,我们在数据库上建专门的只读视图,只暴露业务需要的最小字段集,并且给这些视图加上行级权限过滤。
第二层是接口层:对需要写回的操作,比如把结构化字段回写到病案系统,统一走厂商提供的 API,由网关做鉴权和幂等控制。
第三层是消息队列:对于批量场景,比如随访中心晚上生成几千条提醒话术,我们走异步队列,避免同步调用把模型服务打爆。
一个重要的原则是:模型永远不直接接触患者隐私原始数据。真实患者信息进入模型前,必须经过脱敏替换;需要完整数据做特征抽取时,也是在隔离环境里完成,而不是让模型服务访问生产库。
3. 核心 Skill 的落地过程:从检索到解读都要控制变量
3.1 病历结构化抽取 Skill:先分节再抽取
病历结构化抽取是这个项目里最早完成的 Skill,也是后来其他 Skill 的地基。最初我们尝试直接把整份出院记录扔给模型,让它输出固定字段,结果很不稳定:长病历经常截断,而且模型容易把“现病史”里的内容误填到“既往史”里去。
后来改成先分节、再抽取的两段式流程:先用规则按“主诉”“现病史”“既往史”“体格检查”等标题把文本切开,再对每一节分别做字段抽取。这里有一个 LangChain 里很实用的封装方式:
from langchain_core.tools import tool from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import JsonOutputParser # llm: 通过 LangChain 兼容的 chat model 注入,实例由项目部署环境决定 # pull_required_fields 是字段字典,例如: # {"chief_complaint": "主诉", "past_history": "既往史", ...} @tool def extract_medical_record(record_segments: dict) -> dict: """ 从住院记录中抽取结构化字段。 参数 record_segments 必须按章节切分,键为 section 名,值为段落原文。 返回值是字段名到字段值的 JSON。 """ prompt = ChatPromptTemplate.from_messages([ ("system", "你是病案质控助手。严格按照字段字典抽取,\ 原文没有的内容填 null,禁止根据上下文猜测。"), ("human", "请抽取以下病历片段:\n{record}\n字段字典:{fields}") ]) chain = prompt | llm | JsonOutputParser() result = chain.invoke({ "record": record_segments, "fields": pull_required_fields() }) return result这里最值得说的是“原文没有的内容填 null,禁止猜测”这句话。医疗场景最怕模型脑补,比如病历里没写过敏史,模型为了满足输出格式硬填一个“无”,这个信息就会被直接写进系统,后续再有医生看到就可能造成误判。所以我们的 prompt 里对空值策略做了非常明确的约束,并且在解析层做了后校验:凡是模型输出里出现了原文根本不存在的字段值,直接判定为抽取失败,不落库。
3.2 检验解读 Skill:数值判断交给代码,语言组织交给模型
检验解读 Skill 一开始踩过一个坑:我们让模型直接读“糖化血红蛋白 7.2%”并判断是否异常,结果模型偶尔会把参考范围记错或者混淆不同检测方法。后来我们把任务拆成两段:数值判断用规则代码,语言组织用模型。
@tool def interpret_lab_result(item_name: str, value: float, unit: str) -> dict: """ 解读单个检验指标的数值意义,仅用于健康宣教辅助参考,不作为诊断依据。 item_name 必须是标准化后的院内检验字典名称。 """ normalized = normalize_unit(item_name, unit) # 统一单位 ref_lo, ref_hi = get_ref_range(item_name) # 来自配置表 flag = judge_by_code(value, ref_lo, ref_hi) # 纯代码判断:偏低/正常/偏高 trend_text = { "low": "低于参考范围", "normal": "在参考范围内", "high": "高于参考范围" }[flag] prompt = ChatPromptTemplate.from_messages([ ("system", "你负责把数值判断结果转成通俗易懂的健康宣教文案。\ 不得改变结论,不能添加参考范围以外的医学判断,末尾必须注明\ '本解读仅作参考,具体请咨询医生'。"), ("human", "指标:{name}\n数值判断:{trend}") ]) return prompt.invoke({ "name": item_name, "trend": trend_text })这样拆分之后,方向性错误的概率明显下降了。代码里判高低是确定的,模型只负责用通俗语言把结论说清楚,可发挥空间被压到最小。
还有一个细节:检验指标经常出现单位混用,比如同一种指标有的检验设备出“mmol/L”,有的出“mg/dL”。大家在不同版本里踩过一次很深的坑,从此我们强制要求所有指标先进标准化字典,统一主单位,再进入解读链路。这个标准化逻辑放在实体标准化 Skill 里,检验解读和知识库问答都复用它。
3.3 知识库问答 Skill:RAG 最容易翻车的切块环节
知识库问答这个 Skill 技术栈上就是标准的 RAG,但真正落地时最容易翻车的不是向量检索效果,而是文档切块策略。
我们一开始学通用教程,按固定 token 数切块,比如每 500 字符一块。结果医生问“糖尿病的诊断标准”时,模型经常引用得不够精确,因为诊断标准那一小节被切成了两半,每一块都不完整。
后来改成按文档结构切块:先解析文档目录,按章节层级切片,章节太长的再按段落切。这样每个检索片段在语义上是完整的。另一个必须要做的功能是引用溯源,回答里每个结论都要带上“来源文档名称 + 章节”。
@tool def ask_knowledge_base(question: str) -> dict: """ 面向院内知识库的问答工具,适用于诊疗指南、药品说明书、患者宣教材料。 返回内容必须携带引用信息 source。 """ docs = retriever.invoke(question) context = "\n\n".join( f"[{doc.metadata['source']}|{doc.metadata['section']}] {doc.page_content}" for doc in docs[:4] ) prompt = ChatPromptTemplate.from_messages([ ("system", "基于给定资料回答问题。如果资料中没有答案,\ 直接回答'未能从现有知识库中找到依据'。引用格式为 [来源|章节]。"), ("human", "资料:\n{context}\n问题:{question}") ]) answer = prompt.invoke({"context": context, "question": question}) return {"answer": answer, "sources": [d.metadata for d in docs[:4]]}这里我特别想提醒一件事:知识更新。医疗知识库必须做版本管理,指南一更新,旧版本就应该从检索范围里撤下来。我们曾经因为旧版指南还留在向量库里,出现了新旧答案并存的现象,从那以后每次知识库变更都会走一次版本发布流程。
4. 多 Skill 组合编排:让一个 Agent 接住所有请求
4.1 Agent 选型与参数:我建议用图结构控制流转
单个 Skill 能解决单个场景,但科室的人不会理解 Skill,他们只会对着一个对话框提问。所以我们需要一个编排层,根据问题自动路由到合适的 Skill,甚至串联多个 Skill。
LangChain 生态里有多种 Agent 方案,我们对比过 ReAct Agent 和结构化对话 Agent,最终选了基于图结构的状态流转方式。原因是医疗场景非常在意可控性:模型不能自主地无限循环调用工具,每一次调用都必须在预设的节点范围内发生,并且执行步数要严格限制。
一个简化的编排逻辑可以这样理解:
from typing import TypedDict, Any from langgraph.graph import StateGraph, END class AgentState(TypedDict): task: str skill_results: dict final_answer: str max_steps: int def route_to_skill(state: AgentState) -> AgentState: # 根据任务描述决定调用哪个 Skill,未命中则进入兜底 skill_name = decide_skill(state["task"]) if skill_name is None: state["final_answer"] = "该问题不在当前辅助能力范围内,请补充信息或转人工。" return state state["skill_results"][skill_name] = invoke_skill(skill_name, state["task"]) return state def build_answer(state: AgentState) -> AgentState: # 汇总所有 Skill 结果,生成最终回复 state["final_answer"] = summarize(state["skill_results"]) return state graph = StateGraph(AgentState) graph.add_node("route", route_to_skill) graph.add_node("answer", build_answer) graph.add_edge("route", "answer") graph.add_edge("answer", END)代码里没有展示具体的模型决策函数,因为那属于业务实现,但核心思路是:先用模型做一次路由判断,再按路由结果执行确定性的 Skill 调用,最后汇总生成。这样比完全交给 Agent 自由发挥要稳定得多。
运行参数上我们做了两个硬限制:一是单轮对话最多执行 5 次工具调用,超过就强制结束并转人工;二是模型路由判断的顶层 prompt 里明确写了“只选择与问题直接相关的 Skill,不要为了使用工具而使用工具”。这两条配合下来,线上乱调用工具的情况基本消失了。
4.2 兜底逻辑:宁可说“不知道”,也不要瞎猜
医疗场景里最危险的回答是言之凿凿的错误答案。所以我们从一开始就给编排层定了兜底规则:当模型判断问题不在任何 Skill 能力范围内时,必须返回预设话术,而不是让模型自由发挥。
比如患者家属问“这个药能不能和头孢一起吃”,这明显超出了知识库问答 Skill 的范围如果没有经过药师审核的相互作用数据,模型的任何回答都可能造成风险。我们的编排层会返回:
“该问题需要由药师进一步确认,建议您通过在线问诊或门诊药房咨询窗口获取专业意见。”
这里要强调一下,兜底不是简单的关键词匹配,而是模型路由判断的一部分。我们在路由 prompt 里反复强调“不确定时就选兜底”,并且通过评测样本持续修正它。
4.3 多 Skill 协作时的上下文污染问题
拆成多 Skill 之后,很容易犯的一个错误是让所有 Skill 在一个对话上下文里叠着跑。刚开始我们把病历结构化抽取的完整 JSON 输出直接丢回会话历史,结果下一个 Skill 读上下文时把 JSON 当成病历本身来理解,输出了一堆莫名其妙的结果。
这实际上是上下文污染。解决办法是:每个 Skill 内部使用独立的 prompt 和独立的上下文片段,Skill 返回给编排层的只是一个精炼摘要,而不是它的全部工作过程。比如病历结构化 Skill 对外只返回“抽取完成,字段数 18,待人工复核字段 2 个”,原始 JSON 存入结果库,不在对话上下文里滚动。
这样做还有一个附带好处:token 消耗大幅下降了,每轮对话的上下文长度被严格限制,模型响应速度也更快了。
5. 医疗落地绕不开的硬约束:安全、幻觉、评测
5.1 三层脱敏体系
数据安全是医疗信息化项目的一票否决项。我们的做法是在整个链路里布三层脱敏。
第一层是输入脱敏,在数据进入任何 Skill 之前,先经过一个安全合规 Skill 做检查:匹配身份证号、手机号、住院号等敏感模式并替换成占位符,比如把“张三”替换为“[患者甲]”。这一步是硬编码规则加本地 NLP 服务,不让敏感信息进入模型上下文。
第二层是显示脱敏,即使某个 Skill 从内部接口拿到患者姓名,返回给前端时也要根据当前用户的权限决定是否展示。比如科室主任可以看完整姓名,科室普通医生只能看病案号。
第三层是日志脱敏,模型日志、应用日志里只保留脱敏后的内容。我们曾经在排查问题的时候发现一条日志里带着完整姓名,后来紧急把所有日志输出口统一套了一层脱敏过滤器。
| 层级 | 作用位置 | 主要手段 | 目的 |
|---|---|---|---|
| 输入层 | Skill 调用前 | 正则 + 本地 NLP 替换 | 敏感信息不进入模型上下文 |
| 显示层 | 前端接口 | 行级权限 + 字段过滤 | 医生只能看到自己有权限的数据 |
| 日志层 | 日志系统 | 统一脱敏过滤器 | 审计日志不泄露隐私 |
5.2 幻觉治理的三道闸门
大模型幻觉在医疗场景里是不能接受的存在。我们构建了三道闸门来治理。
第一道闸门在输入侧:把用户的开放问题尽量收敛到 Skill 能力范围内,凡是路由判断为“不在能力范围内”的问题直接走兜底,而不是让模型硬答。
第二道闸门在回答侧:凡是涉及临床结论的输出,强制要求带引用来源,没有来源的回答一律不展示给用户。知识库问答 Skill 本身就带来源字段,检验解读 Skill 则带“仅作参考,请咨询医生”的后缀。
第三道闸门在业务侧:对危急值、过敏原、药品禁忌这类高风险内容做关键词硬校验。比如检验解读一旦出现“危急值”相关判定,不直接推送,而是置顶人工复核标记,由医护人员确认后才可继续后续流程。
这套体系不是某一个 Skill 能单独完成的,它依赖安全合规 Skill 作为中间件在整条链路上做检查。
5.3 我们自建的评测集与每周回归
医疗项目的质量要求让“感觉还行”完全不够。我们从项目一开始就同步建了一套评测集,脱敏整理之后大概有 1200 条真实业务样本,覆盖六大 Skill 的 20 多个细分场景。
评测指标分三层:
- 工具调用准确率:路由是否选对了 Skill,参数是否解析正确。
- 业务效果指标:结构化抽取用 F1,问答用引用完整率,检验解读用方向性错误率。
- 端到端满意度:从用户真实反馈里抽样,由信息科和临床科室代表共同打分。
| 指标 | 目标值 | 实测初值 | 上线后稳定值 |
|---|---|---|---|
| 路由准确率 | 95% | 88% | 96% |
| 结构化抽取 F1 | 90% | 84% | 92% |
| 解读方向性错误 | 0 | 3 例/百次 | 0 例/千次 |
| 引用完整率 | 95% | 80% | 97% |
每周跑一遍回归,任何提示词调整、知识库变更、模型升级都先过回归再上生产。这个过程很枯燥,但它是整个项目能持续稳定运行的底气。
6. 上线之后的运行情况与下一步扩展
6.1 真实运行中的表现和几个教训
系统上线运行了大半年,总体表现符合预期,但也暴露了一些单纯在测试环境发现不了的问题。
病历结构化抽取的上线后 F1 稳定在 92% 左右,但离“全自动无人复核”还有距离。长病历、手写扫描件转出来的文本错字多,结构化质量明显下降。我们最终没有追求全自动,而是保留了人工复核队列,机器先处理、人再抽查。这个定位很重要,因为医疗信息化系统不能为了自动化指标牺牲安全底线。
模型服务的时延是另一个没有预料到的瓶颈。早上高峰时段,同时在线人数一多,模型服务排队时间显著拉长,某些复杂问题甚至会超时。我们后来加了队列、超时重试、以及结果缓存,对同一个患者的同类查询直接命中缓存。
还有一个运维教训:模型版本一升级,表现不一定会变好,反而可能出现个例回归。所以我们把模型也纳入了版本管理,线上跑哪个版本、什么时候切换,都要走变更审批流程。
6.2 后续可以扩展的三条路线
这个多 Skill 底座的可复用价值比我们预想的大。目前我们规划了三个扩展方向。
第一个是多模型路由:把简单任务比如实体标准化交给响应快的小模型,把复杂的病历结构化交给能力更强的大模型,由编排层按任务难度分配。这样能显著降低整体成本。
第二个是随访中心自动化工单:把随访提醒从“生成话术”升级为“生成话术 + 自动拨号任务 + 结果回收”,和现有的随访系统打通。
第三个是科研数据治理:把病历结构化能力复用到科研项目的数据清洗上,从大量历史病历中提取统一格式的队列研究字段,减轻研究人员的人工工作。
还有一个我个人特别想做的方向,是把这套编排能力开放给院内各个科室做自助式配置。科室可以自己定义新字段、新模板、新知识来源,而不需要每次找信息科改代码,真正做到“能力集中建设、业务分散接入”。
如果让我重新做一遍这个项目,我会把顺序调整成:先和科室把字段字典和远期预期对齐,再写代码;先把评测集建起来,再调模型;先把安全和日志体系搭好,再谈复杂编排。总得来说,LangChain 提供的工具封装帮我们省了很多事,但医疗信息化的核心难点从来不在框架本身,而在于把模糊的临床需求翻译成清晰的、可约束、可评测的流程。这个翻译过程,决定了系统能不能在真实业务场景里活下来。