中医药文化源远流长,典籍浩如烟海。对于普通用户而言,想要在《黄帝内经》《伤寒论》等经典中找到对症的方剂和用药建议,往往需要深厚的专业知识。随着大语言模型技术的发展,我们能否打造一个智能问答系统,让用户以自然语言描述症状,系统便能依据中医典籍给出有理有据的建议?带着这样的想法,我开发了「中医药专家问答系统」。本文将详细记录开发过程中的技术选型、架构设计、核心代码实现、遇到的挑战以及后续优化方向,希望能为同样对 AI + 中医药感兴趣的朋友提供参考。

二、技术选型

  • 后端框架:FastAPI(异步高性能,天然支持 SSE 流式响应)

  • 大语言模型:阿里通义千问 qwen-max(通过 LangChain 集成)

  • LangChain 组件

    • ChatPromptTemplate 构建提示模板

    • MessagesPlaceholder 管理对话历史

    • StreamingResponse 实现流式输出

  • 前端:原生 HTML/CSS/JS(简洁易部署,支持 Server-Sent Events)

  • 部署:Uvicorn

三、系统架构

整体采用 B/S 架构,前端负责展示对话界面和用户输入,后端提供两个核心 API:

  • POST /api/chat:处理用户消息,支持流式返回模型思考过程及最终答案。

  • POST /api/reset:重置对话历史,开始新会话。

后端通过 LangChain 调用通义千问模型,并动态维护对话历史,使模型能够记住上下文,提供连贯的咨询体验。

text

用户 → 前端页面 → FastAPI 后端 → 通义千问 API → 流式返回 → 前端实时渲染

四、后端核心实现

4.1 环境与模型初始化

首先设置环境变量 DASHSCOPE_API_KEY,然后创建 ChatTongyi 实例,启用流式模式(streaming=True 可支持逐字输出,但本次使用 stream 方法实现更细粒度的控制)。

import os
from langchain_community.chat_models.tongyi import ChatTongyi

model = ChatTongyi(model="qwen-max")

4.2 构建提示模板与对话历史

为了增强回答的专业性和依据性,我们在 system 消息中要求模型引用中医典籍原文,并采用列表形式使内容清晰易读。同时利用 MessagesPlaceholder 插入历史对话记录。

from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage

chat_prompt_template = ChatPromptTemplate.from_messages([
    ("system", "你是一名资深的中医药专家,精通《黄帝内经》《伤寒论》《本草纲目》等典籍。请结合中医典籍的记载,为患者提供准确的用药建议和注意事项。"
               "回答时应引用具体典籍原文或出处,增强依据性。如果可能,请使用分点或列表形式组织内容,使回答清晰易读。"),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{input}"),
])

# 初始化示例对话,让模型理解上下文格式
history_data = [
    HumanMessage(content="我最近感冒了,怕冷、流清鼻涕,有什么中药推荐?"),
    AIMessage(content="根据《伤寒论》中'太阳病,头痛发热,身疼腰痛,骨节疼痛,恶风,无汗而喘者,麻黄汤主之'的记载,您的情况属于风寒表证,"
                      "可考虑使用麻黄汤加减。但麻黄汤发汗力强,体虚者需慎用,建议在医师指导下服用。"),
    HumanMessage(content="有没有中成药可以替代?"),
    AIMessage(
        content="根据《太平惠民和剂局方》记载,感冒清热颗粒适用于风寒感冒,具有疏风散寒、解表清热的功效。其组方中荆芥穗、防风等成分可对症。"
                "不过若伴有咽喉肿痛,可能需要搭配其他药物。"),
]

4.3 流式聊天接口

核心在于利用 chain.stream() 方法逐块获取模型输出,并通过 Server-Sent Events(SSE)格式实时推送到前端。此外,每次回复完成后,需要将用户输入和模型回复追加到历史列表中,维持多轮对话的上下文。

from fastapi import FastAPI, HTTPException
from starlette.responses import StreamingResponse
import json

chain = chat_prompt_template | model

@app.post("/api/chat")
async def chat(request_data: dict):
    user_input = request_data.get('input', '')
    if not user_input:
        raise HTTPException(status_code=400, detail="请输入内容")

    async def generate_response():
        full_response = ""
        try:
            # 流式获取模型输出
            for chunk in chain.stream({"history": history_data, "input": user_input}):
                content = chunk.content
                full_response += content
                yield f"data: {json.dumps({'type': 'chunk', 'content': content})}\n\n"

            # 对话结束后更新历史
            history_data.append(HumanMessage(content=user_input))
            history_data.append(AIMessage(content=full_response))

            yield f"data: {json.dumps({'type': 'complete', 'response': full_response})}\n\n"
        except Exception as e:
            yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"

    return StreamingResponse(
        generate_response(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no"  # 禁用 Nginx 缓冲,保证实时性
        }
    )

4.4 重置对话历史

用户可能需要开始新的咨询,因此提供重置接口清空历史,恢复初始示例对话。

@app.post("/api/reset")
async def reset():
    global history_data
    history_data = [
        HumanMessage(content="我最近感冒了,怕冷、流清鼻涕,有什么中药推荐?"),
        AIMessage(content="根据《伤寒论》中'太阳病,头痛发热,身疼腰痛,骨节疼痛,恶风,无汗而喘者,麻黄汤主之'的记载,您的情况属于风寒表证,"
                          "可考虑使用麻黄汤加减。但麻黄汤发汗力强,体虚者需慎用,建议在医师指导下服用。"),
        HumanMessage(content="有没有中成药可以替代?"),
        AIMessage(
            content="根据《太平惠民和剂局方》记载,感冒清热颗粒适用于风寒感冒,具有疏风散寒、解表清热的功效。其组方中荆芥穗、防风等成分可对症。"
                    "不过若伴有咽喉肿痛,可能需要搭配其他药物。"),
    ]
    return {'success': True}

4.5 提供前端页面

通过根路由返回 HTML 文件,省去单独部署前端的麻烦。

from fastapi.responses import HTMLResponse

@app.get("/")
async def get_frontend():
    with open("./templates/index.html", "r", encoding="utf-8") as f:
        return HTMLResponse(content=f.read(), media_type="text/html")

4.6 CORS 配置

为了让前端能够跨域请求 API,添加 CORSMiddleware。

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

五、前端实现

前端是一个简单的聊天界面,支持:

  • 输入框发送消息

  • 实时显示模型回复的流式输出(打字机效果)

  • 重置会话按钮

  • 自动滚动到底部

主要 JavaScript 逻辑使用 Fetch API 读取 SSE 流,逐块渲染 Markdown(简单支持加粗、列表等),提升阅读体验。关键代码如下:

async function sendMessage() {
    const input = document.getElementById('userInput').value;
    if (!input) return;

    addMessage(input, 'user');
    document.getElementById('userInput').value = '';
    const responseArea = document.getElementById('responseArea');
    responseArea.innerHTML = '<div class="loading">正在思考...</div>';

    try {
        const response = await fetch('/api/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ input: input })
        });

        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';
        let fullAnswer = '';

        while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buffer += decoder.decode(value, { stream: true });
            const lines = buffer.split('\n');
            buffer = lines.pop();

            for (const line of lines) {
                if (line.startsWith('data: ')) {
                    const data = JSON.parse(line.slice(6));
                    if (data.type === 'chunk') {
                        fullAnswer += data.content;
                        responseArea.innerHTML = formatMarkdown(fullAnswer);
                        responseArea.scrollTop = responseArea.scrollHeight;
                    } else if (data.type === 'complete') {
                        responseArea.innerHTML = formatMarkdown(data.response);
                        addMessage(data.response, 'ai');
                    } else if (data.type === 'error') {
                        responseArea.innerHTML = `<div class="error">错误:${data.error}</div>`;
                    }
                }
            }
        }
    } catch (err) {
        responseArea.innerHTML = `<div class="error">网络错误:${err.message}</div>`;
    }
}

六、运行与测试

6.1 环境准备

  • Python 3.10+

  • 安装依赖:pip install fastapi uvicorn langchain langchain-community langchain-core starlette

  • 设置环境变量 DASHSCOPE_API_KEY(通义千问的 API Key)

6.2 启动服务

将后端代码保存为 main.py,前端 HTML 放入 templates/index.html 目录,执行:

bash

uvicorn main:app --reload --host 0.0.0.0 --port 8000

访问 http://localhost:8000 即可使用。

6.3 交互测试

  • 输入“我最近胃胀、食欲不振,有什么中药调理?”模型会根据《脾胃论》等典籍推荐理气和胃的方剂,并给出饮食建议。

  • 输入“我嗓子疼,感觉有痰咳不出”模型会区分风热或风寒,引用《温病条辨》等典籍分析。

  • 点击重置按钮,清空当前对话,回到初始示例。

七、遇到的问题与解决

  1. 对话历史管理
    初期尝试将历史作为全局变量存储在内存中,但在并发请求下可能出现数据错乱。由于本项目仅为个人使用或小规模演示,暂未引入数据库,后续可使用 Redis 或数据库存储每个会话的历史。

  2. 流式输出时的 SSE 格式解析
    前端需要正确处理可能被分割的 data: 行,通过 buffer 暂存未处理完的数据,保证 JSON 解析不报错。

  3. 模型回答的格式
    模型有时会输出非 Markdown 的文本,前端需简单处理加粗、列表等,提高可读性。也可要求模型按固定格式输出,如“药方:...”,方便前端解析。

  4. 典籍引用的准确性
    虽然 prompt 要求引用原文,但模型可能会生成“编造”的典籍出处。未来可结合 RAG(检索增强生成)从真实数据库中检索相关内容,增强可信度。

八、效果展示

系统运行界面如下(示例截图)

用户发送问题后,模型会逐字输出回答,并在回答中标注典籍出处

九、下一步计划

  1. 引入知识库 RAG
    将《伤寒论》《金匮要略》等典籍分块嵌入向量数据库,检索相关段落后提供给模型,提高回答的准确性和可信度。

  2. 支持多会话
    为不同用户或不同会话分配独立 ID,存储历史,方便用户切换话题。

  3. 前端优化
    增加 Markdown 渲染(支持代码块、表格),并实现“复制回答”按钮。

  4. 部署上线
    将服务部署到云服务器,配置 HTTPS,方便公网访问。

  5. 用户反馈机制
    允许用户对回答进行评价,收集数据进一步优化 prompt。

十、总结

通过本项目,我成功实践了 LangChain 结合通义千问构建中医药智能问答系统的完整流程,并掌握了 FastAPI 流式响应的开发技巧。虽然当前版本还比较简单,但已具备实用的咨询能力,且扩展性强。


源码仓库:[]

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐