从 Workflow 到 ReAct:一个医疗质控 Agent 的设计实录与踩坑反思
这篇草稿好久了,刚好又学了点,今天好好整理后发出来。
一、为什么不是一套 Prompt 搞定?
大客户的诉求很直白:”让大模型读一下病历,打个分。”按常规思路,一套好的 Prompt 确实能应付大多数场景。
但自己从零搭这个系统,没有团队兜底,我必须同时考虑两件事:不同病例的复杂度差异,决定了单一 Prompt 策略必然在”简单病例浪费 token”和”复杂病例能力不足”之间两头不讨好。
一个普通的驱虫复诊,和一个同时涉及 4 种药物联用、肝肾功能异常、既往胰腺炎病史的复杂内科病例,对推理深度的要求完全不同。前者用单轮 Workflow 足够,后者必须给 Agent 工具调用和多轮推理的能力。
这个判断最终演化成了整个系统最核心的架构决策:双路径评估(Dual-Path Evaluation)。
二、架构全景:Workflow 为盾,ReAct 为矛
系统的入口是一个 CaseRouter,它读取一份外置的 risk_catalog.json,对病例做信号检测和复杂度评分:
- 高危药物(如糖皮质激素、化疗药)
- 药物-疾病冲突
- 多药联用
- 必须按指南执行的诊断
- 主诉中的危急信号
这些信号的权重是可配置的(1.5 ~ 3.0),累加后得到一个 complexity_score。阈值设在 1.5,同时要求 tool_plan 非空,才会被路由到 ReAct Agent。其余 70%~80% 的病例走固定 Workflow。
1 | POST /api/v1/evaluations |
这个分层不是炫技,而是成本与效果的精确博弈。在生产环境中,Workflow 的延迟约 13 秒,ReAct 由于多轮 LLM 调用,延迟在 38 秒,且 token 消耗通常是前者的 3~5 倍。让简单病例为复杂病例的架构买单,是不负责任的。
三、踩坑实录:代码能跑,但架构在痛
坑 1:AgentState 的上下文幻觉
我最初给 ReAct Agent 设计了一个 AgentState 来管理多轮对话的短期记忆。它记录了每一次工具调用的输入、输出和摘要,并在超过 6000 token 时触发 compact()。
问题出在这个压缩策略上:它简单地保留了最近 3 条观察,把更早的记录折叠成一段纯文本摘要。没有语义重要性判断,没有工具调用结果的优先级排序。
这意味着什么?如果 Agent 在第 1 轮调用了 query_standard 获取了关键诊疗指南,但在第 4~5 轮因为工具调用过多触发了 compaction,这份指南的详细内容可能会被粗糙地压缩成一句”已查询相关标准”,导致后续推理失真。
教训:基于字符数的 token 估算(char/4)和基于条数的截断,只能算是”能跑”,离”可靠”差得很远。后续必须用语义摘要或重要性评分来替代。
坑 2:ReAct 循环里的消息格式泥潭
ReAct Agent 的核心是一个 _run_react_loop,最多 5 轮、最多 10 次工具调用。每一轮,我需要手动拼接 OpenAI 格式的消息列表:
system角色定义工具和评分维度user角色注入病例 +tool_plan+ RAG 检索结果assistant角色的tool_callstool角色的返回结果
这个嵌套循环(Agent 外层循环 + Provider 内层调用)让调试变得异常痛苦。有一次,LLM 明明返回了 tool_calls,但我在日志里只看到 content 为空,花了整整一个下午才发现是消息列表里的 role 顺序在某些边界情况下错位了。
教训:手写 ReAct 循环对消息格式的容错性极低。如果团队资源允许,应该尽早抽象出一个 “Turn Manager”,把消息构建、工具调用、结果回写封装成原子操作,而不是在 Agent 主逻辑里裸操作字典列表。
坑 3:Qdrant 与 PostgreSQL 的”半同步”陷阱
系统用 PostgreSQL 作为唯一数据源(Source of Truth),Qdrant 作为检索加速层。这本身没问题,但我在设计审核流程时犯了一个经典错误:
当医生审核通过一条 Few-Shot 示例或 Prompt 规则时,系统需要同时更新 PostgreSQL 的状态字段,并把新数据写入 Qdrant。这两个操作不在同一个事务里。
结果是:PG 提交成功,Qdrant 写入失败(比如网络抖动),系统就会进入不一致状态——PG 显示”已批准”,但检索端查不到这条数据。更麻烦的是,由于没有事务回滚机制,修复需要手动补偿。
任何跨存储的写操作,要么做成真正的分布式事务(Saga / 2PC),要么把 Qdrant 降级为”异步最终一致”,并通过后台任务持续对账。我最终选择了后者,但应该在第一版就明确这个策略,而不是事后补丁。
坑 4:Embedding 的沉默降级
Embedding 层我用 sentence-transformers 加载 text2vec-base-chinese,同时为了测试环境能跑,写了一个 SimpleHashEmbeddingClient 作为 fallback。
这个 fallback 的逻辑是把文本做 SHA-256,然后映射成一个固定维度的伪向量。它保证了”接口不报错”,但语义相似性完全失效——同样的文本永远得到同样的向量,不同的文本可能映射到相近的向量空间位置纯属巧合。
最危险的是,这个降级是静默的。如果生产环境的 sentence-transformers 因为依赖问题没能加载,系统不会崩溃,只会默默返回毫无意义的检索结果。
降级策略必须有明确的日志告警和人工介入机制。”能跑”不等于”可用”。
回头看坑 3 和坑 4,本质上是同一类问题:**”可工作”与”可靠”之间隔着一条静默失败的鸿沟。**Qdrant 写入失败后系统不会报错,Embedding 降级后系统不会报警——它们都在你眼皮底下悄悄背叛了你。生产环境中,一个明确报错的 500 远比一个悄悄返回错误结果却 HTTP 200 的系统要安全。生产系统不允许静默失败。如果做不到强一致性,至少要把”我降级了”这件事喊出来。
四、那些”对的”决定
踩坑之外,也有一些早期决策在后续迭代中被证明是正确的。这些决策背后有一条共同的主线:确定性逻辑(传统代码)做主干,大模型只做理解和分类。Agent 不是把一切交给 LLM 去”聪明地判断”,而是用代码框死边界,只在模糊地带请 LLM 出手。
1. 显式 Mock 开关
我在 LLM 路由层强制要求 LLM_USE_MOCK 必须是显式的布尔配置,绝不允许通过 API Key 的前缀或格式来”推测”是否使用 Mock。
这看起来是多此一举,直到有一次我在测试环境误配了 Key,系统如果走”自动推断”逻辑就会用真实模型跑测试,几分钟内烧掉几十块钱。显式开关让这种事故不可能发生。
2. Workflow 作为默认路径
团队里曾有争论:既然 Agent 更酷、更灵活,为什么不全部走 ReAct?
我的坚持基于一个朴素的工程原则:不要把通用炮当狙击枪用。简单病例走固定流程,不仅快、便宜,还更容易追溯和审计。复杂病例的 Agent 输出需要被标记、被抽样、被人工复核,这个成本必须被限制在 20%~30% 的真正复杂病例上。
3. Risk Catalog 外置化
把路由规则写成 JSON 文件而不是硬编码在 Python 里,让运营同学可以在不发布版本的情况下调整权重和关键词。这对一个医疗场景至关重要——新的高危药物或指南更新随时可能出现,代码发布周期赶不上业务变化。
4. 工具返回必须带 summary
我在工具层强制要求每个工具返回的 JSON 里必须包含一个 summary 字段。这个字段被 AgentState 用于 compaction 和 prompt 重建,避免了把完整的原始数据(比如 20 条历史病历或 10 页指南)反复塞进 LLM 上下文。
5. 硬编码上限:死循环计数器
ReAct 循环我设了两个硬上限:最多 5 轮推理、最多 10 次工具调用。触发上限后直接终止并报警,而不是让 Agent “再想想”。
这不是拍脑袋的数字。Agent 自主推理必须被硬编码上限约束,否则就是一个 Token 消耗黑洞。我在开发早期就踩过这个坑:一次因为 LLM 返回了格式异常的 tool_calls 导致 Agent 反复重试,如果不设上限,几分钟就能烧掉上万个 token。上限不是限制 Agent 的能力,而是保护系统不被 Agent 的”不确定性”拖垮。
6. 代码校验而非自我反思
学术论文里常见的套路是让 LLM “检查一下刚才的输出对不对”(Self-Reflection),在工业界这是一个昂贵的陷阱。它会带来双倍的延迟和双倍的 Token 成本,而且 LLM 审查自己的输出跟请作弊学生给自己监考没区别。
这个系统里,所有校验逻辑都由后端代码完成:ConsultationValidator 在评估前硬性拦截非法病历、Pydantic strict=True 拒绝任何多余字段的输出、评分维度用 model_validator 确保分项不超过满分上限、路由决策全走关键词规则而非 LLM 判断。LLM 只管生成,代码只管校验。不要让 LLM 给自己当裁判。
五、怎么知道 Agent 变好了还是变坏了?
前面聊的都是 Agent 怎么”做”,但一个更深刻的问题是:你改了 Prompt、调了路由阈值、换了 embedding 模型之后,怎么知道系统是变好了还是变坏了?没有量化的评估体系,Agent 的每一次改动都是在盲飞。
分层评估策略
我参考了业内的通用做法,把评估拆成了三个层级:
节点级(Node-Level):单独测试 CaseRouter 的路由准确率。我写了一个 agent_eval.py,加载手工标注的 JSONL 测试集,跑一轮后计算路由准确率、工具精确率/召回率,以及——这个在医疗场景最关键——高风险漏报率(应该走 ReAct 却路由到了 Workflow 的比例)。这个指标必须无限趋近于零。
组件级(Component-Level):针对 RAG 检索,需要检验检索到的 Few-Shot 示例是否真的相关、知识文档是否覆盖了问题域。这对应业界的 RAGAS 框架(忠实度、答案相关性、上下文召回率)。目前我主要靠人工抽查,但自动化 RAGAS 评估是优先级最高的待办。
端到端(End-to-End):终极指标只有一个——医生采纳率(Adoption Rate)。评估结果出来后,医生可以选择”采纳”或”拒绝”。采纳率高的评估就是好评估,拒绝率高的就是坏评估。这个反馈闭环是 Phase 3 进化的唯一燃料。
Golden Dataset 和 LLM-as-a-Judge
评估自动化依赖两样东西:
一是 Golden Dataset(金色数据集)——从历史评估中精选 100~500 个代表性病例,人工标注”标准答案”(应该走什么路径、各维度得分预期、应该查哪些知识文档)。这是你最重要的测试资产。
二是 LLM-as-a-Judge——用一个更强的模型(如 GPT-4o 或 Claude)来当裁判,把(用户输入 + Agent 输出 + Golden Dataset 标准答案)一起喂给它,让它按严格的评分 Rubric 打维度分。没有自动化裁判,评估体系就跑不起来。
这两样我目前都还在建设中。Golden Dataset 只有 3 条标注样本,LLM-as-a-Judge 还没接入。但方向是明确的,代码框架(agent_eval.py)已经铺好了。
线上链路追踪
最后,生产环境必须有 Trace 系统(LangSmith / Langfuse / Phoenix)。它们会把 Agent 一次完整执行的调用链拆解成一棵树——你能看到:请求进了哪个 Router → 耗时多少 → 调用了哪些 Tool → 每个 Tool 返回了什么 → 最终 LLM 如何组装输出。线上出了任何诡异回答,顺着 Trace 一眼就能定位到具体节点。
这一点我目前还没做,但放在这里是作为一条必经之路——没有 Trace,Agent 就是黑盒;有了 Trace,Agent 才是白盒。
六、Phase 3 进化系统:造好的船,等水到位
项目规划了三个阶段:
- Phase 1:固定 Workflow(已完成)
- Phase 2:ReAct Agent(已完成)
- Phase 3:自我进化(Pipeline 代码已完成,但未接入定时触发)
Phase 3 的核心是 EvolutionScheduler,完整的流水线为:
1 | run_evolution() |
每一次”候选”都通过 AgentReviewTask 进入人工审核流程,状态机为:pending_review → approved / rejected。不允许自动发布,这是对医疗场景的刻意保守。
Pipeline 完整的部分包括 EvolutionScheduler.run_evolution()(主流水线编排)、FewShotStatsAggregator(采纳率统计)、SampleExtractor(样本提取)、BadCaseAnalyzer(拒绝模式分析)。
尚未完成的部分是定时触发机制的接入。这是有意为之的克制——在 Phase 1 和 Phase 2 的稳定性没有足够数据支撑之前,启动自我进化等于在没有刹车的情况下踩油门。
七、架构全貌:那些容易被忽略的模块
以下几个实际存在但容易被忽略的模块,构成了系统的底座:
ConsultationValidator(数据校验层) —— ReAct 执行前必须通过数据合法性校验,拦截不合法病历。若校验失败,评估直接标记为 failed,不进入 ReAct 推理链。这是一道数据质量门。
AgentEvaluation 完整持久化模型 —— 评估结果写入数据库,包含 status、eval_mode、model_version、few_shot_ids、kb_doc_ids、total_score、result_json 等字段,为后续的 EvolutionScheduler 提供反馈数据来源。
多租户隔离 —— 所有方法显式传入 tenant_id,AgentEvaluation 等模型均以此为主要查询索引,确保数据隔离。
Few-shot + Knowledge 双检索 —— ReAct 执行前并行检索 Few-shot 示例(提供参考案例)和 Knowledge 文档(提供指南/标准),两者共同注入 prompt。
输出护栏 —— ConsultationValidator 管住输入端之后,输出端同样不能裸奔。评分结果用 Pydantic strict=True 模式解析,任何多余字段都会被拒绝;维度分通过 model_validator 确保不超过 25 分的上限;LLM 返回的 JSON 自动剥离 markdown code fence(```json 包裹的情况极其常见)。输入端防污染,输出端防事故,缺一不可。
BigInt 兼容 —— 数据库主键是 64 位整数,Python 端能正常处理,但通过 REST API 返回给前端时,JavaScript 的 Number 类型会丢失精度。所有 API 响应里的 ID 字段都通过 Pydantic field_validator 强制转为 string,前端永远不做精度假设。
优雅降级作为系统级设计 —— Qdrant 客户端加载失败时不是 crash,而是所有检索方法 return empty/no-op;Embedding 模型加载失败时 fallback 到 hash 伪向量。整个系统的设计原则是:任何一个外部依赖挂了,系统能降级运行,但一定会把”我降级了”这件事打出来。——对应坑 4 的教训。
八、写在最后
写 Agent 和写传统后端服务最大的区别是:不确定性不是 bug,而是特性的一部分。你无法像测试一个 REST API 那样精确断言 LLM 的输出,你只能设计边界、设计 fallback、设计人在回路(Human-in-the-Loop)。
这个项目教会我最重要的一课是:Agent 的架构质量,不体现在它有多”智能”,而体现在它有多”可控”。
双路径设计是对可控性的妥协,显式 Mock 是对可控性的坚守,人工审核的进化系统是对可控性的敬畏。医疗 AI 不允许惊喜,只允许可预期的、可解释的、可回滚的行为。
而这条”可控性”主线的底层,是同一个设计哲学:确定性逻辑(传统代码)做主干,大模型只做理解和分类。路由用关键词不用 LLM,校验用 Pydantic 不用自反思,超时和重试用计数器不用 prompt 约束——所有能代码化的边界,绝不让 LLM 来”商量”。
已知改进方向
如果时间允许,以下几件事是明确的下一步:
- RAG Re-rank:向量检索后加一层 Cross-Encoder 精排,把最相关的 Top 3 文档强行喂给 LLM。这是 RAG 效果提升里性价比最高的一步。
- Multi-Agent 拆分:当前 ReAct Agent 一个人干所有事(查药、查化验、查指南、评分)。当某个环节频繁出错时,把它拆成独立子 Agent,职责越聚焦,效果越可控。
- 接入 Trace 系统(LangSmith / Langfuse / Phoenix):把每一次 Agent 执行的完整调用链可视化。没有 Trace,调 Agent 等于盲人摸象。
- 完善 Golden Dataset + LLM-as-a-Judge:把评估自动化跑通,让每一次 Prompt 改动都有量化反馈。
Phase 1 和 Phase 2 已在线上运行。Phase 3 的 Pipeline 代码已完成,调度触发器待接入——这是一艘造好的船,等待数据的水位足够高,才允许启航。
Agent 不是终点,而是一个持续收敛的过程。