本文适合刚学完 Vue、Python 后端和数据库,想做一个完整 AI 应用项目的同学。 项目仅用于学习演示,不能直接作为真实医疗诊断系统使用。

项目已经开源,源码地址: https://gitee.com/hn2004214/medical-consultation-system

这个地址就是项目的 Gitee 页面,读者可以打开查看源码,也可以通过 git clone 拉取到本地运行。

1. 这个项目解决什么问题

很多 AI 项目只做了一个聊天框:

用户输入一句话 -> 调用大模型 -> 返回一段话

这样能演示 AI 效果,但还不像一个真正的业务系统。我的理解是,AI 应用真正难的地方不是“调一次接口”,而是要处理用户、数据、流程、状态和人工审核。

医疗问诊场景更明显。患者说“我头疼”,医生还需要继续了解:疼多久了、有没有发热、有没有呕吐、有没有基础疾病、最近有没有外伤或感染。如果这些信息不完整,AI 直接给建议就不可靠。

所以这个项目的设计思路是:

AI 先追问症状 -> AI 整理预检分诊建议 -> 医生审核确认

重点不是让 AI 替代医生,而是让 AI 做前置信息收集和初步整理,医生负责最终判断。

2. 业务流程

项目分为患者端和医生端,主流程如下:

患者注册 / 登录
  ↓
填写健康档案
  ↓
创建问诊会话
  ↓
患者描述症状
  ↓
AI 根据症状继续追问
  ↓
患者点击“生成分诊建议”
  ↓
AI 输出结构化分诊结果
  ↓
会话进入“待医生审核”
  ↓
医生查看患者资料、聊天记录、AI 建议
  ↓
医生选择:通过 / 打回重诊 / 打回追问
  ↓
患者查看结果或继续补充信息

这里有三个关键角色:

角色 作用
患者 填写档案、描述症状、查看结果
AI 追问症状、生成预分诊建议
医生 审核 AI 建议,决定通过或打回

这个闭环是项目的核心。它比普通 AI 聊天多了一层“医生审核”,所以业务边界更清楚。

3. 技术栈与模块分工

项目技术栈如下:

模块 技术 负责什么
前端 Vue2、Vite、Element UI、Axios 页面展示、表单、接口请求
后端 FastAPI、Pydantic、SQLAlchemy Async 接口、业务逻辑、数据库操作
数据库 MySQL 保存用户、档案、会话、消息、审核记录
登录 JWT 区分当前操作用户
AI Dify Workflow 追问症状、生成分诊建议
报告 jsPDF、html2canvas 导出审核后的报告

几个概念简单理解:

  • FastAPI:Python 写接口的框架。

  • Pydantic:校验前端传来的参数。

  • SQLAlchemy:用 Python 操作 MySQL。

  • JWT:登录后的身份凭证。

  • Dify Workflow:把 AI 流程拆成可配置节点。

前端不直接调用 Dify,而是走:

前端 -> 后端 -> Dify

这样 Dify Key 不会暴露在浏览器里。

4. 数据库和状态机

这个项目的核心表可以理解为五类:

作用
users 用户账号,包含患者和医生
user_profiles 患者健康档案
conversations 一次问诊会话
messages 会话里的每一条聊天消息
admin_reviews 医生审核记录

为什么要把 conversationsmessages 分开?

因为一次问诊会有很多轮聊天。conversations 表示“一次问诊”,messages 表示“这次问诊里的一句话”。

会话状态是项目里很重要的业务控制:

class ConversationStatus(str, enum.Enum):
    ACTIVE = "active"                 # 进行中
    PENDING_REVIEW = "pending_review" # 待审核
    REVIEWED = "reviewed"             # 已审核

三个状态的含义:

状态 含义
active 患者可以继续和 AI 对话
pending_review AI 已生成分诊建议,等待医生审核
reviewed 医生已经审核完成

没有状态控制,患者可能在医生审核时继续改症状,医生看到的数据就不稳定。这个状态机就是项目的业务保护线。

5. 患者发送消息:前端代码链路

前端接口封装在 frontend/src/api/triage.js

export function sendMessage(data) {
  return request.post('/triage/send-message', data);
}

患者聊天页会调用这个接口。核心逻辑可以简化成:

async sendToAi(content, targetNode) {
  const resp = await triageApi.sendMessage({
    conversation_id: this.conversation.id,
    content,
    ...(targetNode ? { target_node: targetNode } : {}),
  });
​
  await this.loadConversation(this.conversation.id);
​
  if (resp.is_final) {
    this.$message.success('AI 已生成分诊建议,正在转交医生审核');
  }
}

这里有两个关键字段:

  • conversation_id:告诉后端这条消息属于哪次问诊。

  • target_node:告诉后端这次走 followup 还是 triage

普通聊天默认走 followup,也就是继续追问。患者点击“生成分诊建议”时,前端会传:

await this.sendToAi(content, 'triage');

所以 target_node 可以理解成前端、后端、Dify 之间的流程开关。

6. 后端入口:FastAPI 路由和参数校验

前端请求会进入 backend/app/api/v1/triage.py

@router.post(
    "/send-message",
    response_model=SendMessageResponse,
    summary="发送消息(触发 AI 工作流)",
)
async def send_message(
    payload: SendMessageRequest,
    current_user: User = Depends(require_patient),
    db: AsyncSession = Depends(get_async_db),
):
    return await triage_service.send_message(db, current_user, payload)

这段代码只做三件事:

  1. 接收 /triage/send-message 请求。

  2. 通过 require_patient 确认当前用户是患者。

  3. 把业务交给 triage_service.send_message

请求参数定义在 backend/app/schemas/triage.py

class SendMessageRequest(BaseModel):
    conversation_id: int = Field(..., gt=0, description="会话 ID")
    content: str = Field(..., min_length=1, max_length=2000, description="患者消息内容")
    target_node: Literal["triage", "followup"] | None = Field(default=None)

这里限制了:

  • conversation_id 必须大于 0。

  • content 不能为空,最长 2000 字。

  • target_node 只能是 triagefollowup

我的理解是,参数校验越靠前,后面的业务代码越干净。

如果有人传错参数,比如 target_node="abc" 或消息为空,请求会在进入业务逻辑前就被拦住。这样 triage_service 不需要再处理一堆脏数据,只专心写问诊业务。

7. 后端核心:triage_service.send_message

患者发送一条消息后,后端主流程在 backend/app/services/triage_service.py

可以概括为:

查会话 -> 校验归属 -> 校验状态 -> 保存患者消息
-> 查询患者档案和历史消息 -> 调用 Dify
-> 保存 AI 回复 -> 如果是最终分诊结果,更新为待审核

先看状态校验:

if conv.status == ConversationStatus.PENDING_REVIEW:
    raise HTTPException(
        status_code=status.HTTP_400_BAD_REQUEST,
        detail="会话正在等待医生审核,暂不可继续问诊",
    )

if conv.status == ConversationStatus.REVIEWED:
    raise HTTPException(
        status_code=status.HTTP_400_BAD_REQUEST,
        detail="该会话已审核完成,请创建新会话",
    )

这保证了两件事:

  • 等待医生审核时,患者不能继续改内容。

  • 医生审核完成后,这次会话不能再被修改。

然后保存患者消息:

await message_repo.create(
    db,
    conversation_id=conv.id,
    role="user",
    content=payload.content,
)

接着准备传给 Dify 的上下文:

profile = await profile_repo.get_by_user_id(db, user.id)
profile_summary = build_profile_summary(profile)

history_msgs = await message_repo.list_by_conversation(db, conv.id)
history_payload = [
    {"role": m.role, "content": m.content} for m in history_msgs
]

AI 问诊不是单轮问答,它需要知道患者档案和历史对话。否则患者前面已经回答过的问题,AI 后面可能又问一遍。

最后决定走哪个工作流节点:

target_node = payload.target_node or conv.current_node or "followup"

优先级是:

前端显式指定 > 会话当前节点 > 默认 followup

8. Dify 工作流:followuptriage

项目里 Dify 主要有两个分支:

节点 作用
followup 继续追问,补全症状信息
triage 综合已有信息,生成分诊建议

后端调用 Dify 的代码封装在 backend/app/services/dify_client.py。核心请求体是:

payload = {
    "inputs": {
        "user_message": user_message,
        "conversation_id": str(conversation_id),
        "patient_profile": patient_profile or "",
        "target_node": target_node,
        "history": json.dumps(history or [], ensure_ascii=False),
        "doctor_reason": doctor_reason or "",
    },
    "response_mode": "blocking",
    "user": f"patient-{conversation_id}",
}

注意这几个点:

  • inputs 里的字段名必须和 Dify 开始节点变量一致。

  • target_node 决定走 followup 还是 triage

  • history 被转成 JSON 字符串传入工作流。

  • blocking 表示后端等待 Dify 返回完整结果。

如果没有配置真实 Dify Key,项目会走 Mock 模式,方便先跑通前后端和数据库流程。

Dify 返回后,后端会整理成统一格式:

return {
    "is_final": is_final,
    "assistant_text": assistant_text,
    "triage_payload": triage_payload,
}

三个字段的含义:

字段 含义
is_final 是否已经生成最终分诊建议
assistant_text 展示给患者看的 AI 回复
triage_payload 结构化分诊结果

如果是 followup 分支,后端会强制保持非最终态:

if target_node == "followup" and explicit_final is None:
    is_final = False

这样可以避免追问节点误把会话推进到医生审核。

分诊节点最好让 Dify 输出固定 JSON。比如:

{
  "recommended_dept": "神经内科",
  "possible_causes": ["偏头痛", "上呼吸道感染相关头痛"],
  "advice": "补水休息,监测体温和血压,症状加重及时线下就医。",
  "urgency_level": "一般",
  "is_final": true,
  "answer": "已根据您的描述生成初步分诊建议。"
}

这样后端才能稳定保存 recommended_depturgency_level 和完整 triage_result。如果让 AI 自由输出一大段自然语言,页面展示和数据库保存都会变麻烦。

9. 生成分诊建议后,如何进入待审核

当 Dify 返回 is_final=True,后端会保存结构化分诊结果,并把会话状态改成 pending_review

核心代码:

if dify_result["is_final"]:
    raw_payload = dify_result["triage_payload"] or {}
    triage_payload = TriageResultPayload(**raw_payload)

    await conversation_repo.update_triage_result(
        db,
        conv,
        recommended_dept=triage_payload.recommended_dept,
        urgency_level=triage_payload.urgency_level,
        triage_result=raw_payload,
    )

    await conversation_repo.update_status(
        db,
        conv,
        status=ConversationStatus.PENDING_REVIEW,
        current_node="triage",
    )

这段代码完成两件事:

  1. recommended_depturgency_level、完整 triage_result 保存到会话。

  2. 把状态从 active 改成 pending_review

从这一步开始,患者不能继续修改当前问诊,直到医生审核或打回。

10. 医生审核闭环

医生审核页支持三种动作:

动作 含义
approve 审核通过
reject_retriage 打回重新分诊
reject_followup 打回继续追问

前端提交审核时,会传 review_type 和审核意见:

const payload = {
  review_type: reviewType,
  review_text: this.reviewForm.review_text,
};

if (
  reviewType === 'reject_retriage' &&
  this.reviewForm.regenerate_fields.length > 0
) {
  payload.regenerate_fields = this.reviewForm.regenerate_fields;
}

await doctorApi.submitReview(this.detail.id, payload);

review_text 不只是备注,也可以作为医生给 AI 的修正意见。比如医生认为推荐科室不准确,就可以写明原因,让 AI 重新生成。

后端处理逻辑在 doctor_service.py

if review_type == ReviewType.APPROVE:
    await conversation_repo.update_status(
        db, conv, status=ConversationStatus.REVIEWED
    )
else:
    target_node = (
        "triage" if review_type == ReviewType.REJECT_RETRIAGE else "followup"
    )

    await conversation_repo.update_status(
        db,
        conv,
        status=ConversationStatus.ACTIVE,
        current_node=target_node,
    )

    await _retrigger_dify(
        db,
        conv=conv,
        target_node=target_node,
        doctor_reason=payload.review_text,
        regenerate_fields=payload.regenerate_fields,
    )

这段逻辑可以这样理解:

  • 医生通过:会话变成 reviewed

  • 打回重诊:会话回到 active,Dify 走 triage

  • 打回追问:会话回到 active,Dify 走 followup

如果医生只想让 AI 改某几个字段,前端会传 regenerate_fields。后端会把医生选择的字段拼进提示词,并在新结果回来后只合并这些字段。这个设计比“全部重来”更稳。

字段合并的思路可以简化成:

old_payload = dict(conv.triage_result or {})
merged = dict(old_payload)

for field in regenerate_fields:
    if field in raw_payload:
        merged[field] = raw_payload[field]

比如医生只勾选 recommended_dept,那后端只采纳 AI 新生成的推荐科室,其它字段继续保留原来的结果。这样能减少 AI 重新生成时带来的不稳定。

11. 前端如何配合状态变化

后端负责真正限制操作,前端负责把状态解释给用户。

患者端需要展示:

  • 当前会话状态。

  • AI 聊天记录。

  • 分诊建议。

  • 医生审核意见。

  • 是否还能继续输入。

当会话是 pending_review 时,页面应该提示“正在等待医生审核”,并禁用输入框。这样用户不会误以为系统卡住了。

医生端需要展示:

  • 患者基本资料。

  • 健康档案。

  • 历史聊天记录。

  • AI 分诊结果。

  • 审核操作按钮。

医生审核不能只看 AI 结论,还要能看到患者上下文,否则审核没有意义。

PDF 导出也应该放在 reviewed 之后,因为 pending_review 只是 AI 建议,还不是医生确认后的结果。

12. 本地运行步骤

先创建数据库:

CREATE DATABASE medical_consultation
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

启动后端:

cd backend
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
uvicorn app.main:app --reload

.env 里重点配置:

DATABASE_URL=你的数据库连接
SECRET_KEY=你自己生成的随机字符串
DIFY_API_KEY=你的 Dify API Key
DIFY_WORKFLOW_ID=你的 Dify Workflow ID

如果还没有配置 Dify,可以先用占位值走 Mock 模式。

启动前端:

cd frontend
npm install
npm run dev

如果要接真实 Dify,需要在 Dify 导入项目 DSL,绑定自己的模型,发布工作流,然后把 API Key 和 Workflow ID 写入后端 .env

建议第一次运行按这个顺序测试:

1. 先启动 MySQL 和后端
2. 再启动前端
3. 注册患者账号并填写档案
4. 创建问诊会话,发送一条症状描述
5. 点击生成分诊建议
6. 登录医生端审核
7. 回到患者端查看审核结果

如果这条链路能走通,再去接真实 Dify。这样排查问题更简单,不会把前端、后端、数据库、AI 配置混在一起查。

13. 常见问题

后端连接不上数据库

先检查 MySQL 是否启动、数据库名是否是 medical_consultation.env 中的连接字符串是否正确。

前端请求接口失败

先确认后端是否启动,再看浏览器控制台和后端终端日志。404 多半是接口地址问题,500 多半要看后端报错。

AI 没有真实回复

如果 Dify Key 还是占位符,项目会走 Mock 模式。要使用真实 AI,需要检查 DIFY_API_KEYDIFY_WORKFLOW_IDDIFY_BASE_URL,以及 Dify 工作流是否已发布。

生成分诊建议后不能继续聊天

这是正常设计。会话进入 pending_review 后,患者需要等待医生审核。医生打回追问后,状态才会回到 active

Dify 输出解析失败

分诊节点最好要求 AI 输出固定 JSON,例如包含:

recommended_dept、possible_causes、advice、urgency_level、is_final、answer

AI 输出越规范,后端越容易稳定解析和保存。

14. 开源和医疗边界

这个项目已经开源到 Gitee:

https://gitee.com/hn2004214/medical-consultation-system

读者可以直接打开这个网站查看项目代码,也可以使用下面的命令拉取:

git clone https://gitee.com/hn2004214/medical-consultation-system.git

开源时不要提交:

  • .env

  • 数据库密码

  • Dify API Key

  • 真实患者数据

  • venv

  • node_modules

  • 构建产物

可以提交源码、Dify DSL 示例、README、SECURITY、LICENSE 和 .env.example

另外要明确说明:这个项目是学习演示项目,不能直接用于真实医疗诊断。它的设计重点是“AI 辅助整理 + 医生审核”,不是“AI 自动看病”。

15. 总结

这个项目可以总结成四句话:

Vue2 负责页面和交互
FastAPI 负责接口和业务规则
MySQL 负责保存会话数据
Dify 负责 AI 追问和分诊建议

最核心的链路是:

患者发消息
  -> 后端保存消息并调用 Dify
  -> Dify 根据 target_node 追问或分诊
  -> is_final=True 时进入 pending_review
  -> 医生 approve / reject_retriage / reject_followup

我的理解是,AI 应用开发真正重要的能力,是把 AI 放进一个清晰、可控、能落地的业务系统里。这个项目不复杂,但前端、后端、数据库、AI 工作流和医生审核都串起来了,很适合作为 AI 应用入门练习。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐