---
title: 从 Workflow 到 ReAct:一个医疗质控 Agent 的设计实录与踩坑反思
author: Gamehu
date: 2026-05-27 12:00:00
tags:
- AI
- Agent
- ReAct
- 架构设计
- 医疗AI
categories:
- AI
---
AI
Agent
ReAct
架构设计
我们为大客户发现了宠物医疗质控这个痛点场景。为了快速让客户看到效果、同时证明产品能力,我一个人从零开干——不要架构的完美,只要快、简单、能见人。这篇文章没有花架子,只有从 Workflow 到 ReAct 的真实决策、踩过的坑、以及回头看的反思。
---
## 一、为什么不是一套 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。
```
POST /api/v1/evaluations
│
▼
[CaseRouter] ── complexity_score >= 1.5 ──► [ReAct Agent + Tools]
│ │
└─ 简单病例 ─────────────────────────────► [EvaluationWorkflow]
```
这个分层不是炫技,而是成本与效果的精确博弈。在生产环境中,Workflow 的延迟约 1~3 秒,ReAct 由于多轮 LLM 调用,延迟在 3~8 秒,且 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_calls`
- `tool` 角色的返回结果
这个嵌套循环(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`,完整的流水线为:
```
run_evolution()
├─ Step 0: 聚合反馈指标(adoption_rate / rejection_rate)
├─ Step 1: 提取高质量样本 → 创建 Few-shot 候选(pending_review)
├─ Step 2: 分析 Bad Case → 创建 Prompt Rule 候选(pending_review)
└─ Step 3: 低质样本 → 创建停用审核任务(pending_review)
```
每一次"候选"都通过 `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 不是终点,而是一个持续收敛的过程。**