从这则新闻说起
2026年5月,《黑袍纠察队》主演Erin Moriarty公开了自己与Graves病抗争的经历。她在拍摄最终季时出现心悸、手抖、体重骤降等症状,但在确诊前经历了数月的困惑和误诊——这几乎是所有罕见/自身免疫病患者共同的困境:症状不典型,医生无法立即判断,患者自己又缺乏可靠的信息渠道。
作为开发者,我读到这篇的第一反应是:我们能不能做一个工具,让用户输入症状,立刻得到基于权威医学指南的分析和建议?
这不是在教唆自诊,而是帮患者在就医前理清思路、准备问题、加速准确诊断。Graves病的误诊率高达30%(根据美国甲状腺协会数据),如果AI能将这个比例降低哪怕几个百分点,价值巨大。
本文我会直接给你一个可运行的项目原型:一个基于RAG(检索增强生成)的在线症状查询助手。你可以在本地跑起来,或者一键部署到Vercel。
效果演示
用户输入症状描述(如“心悸、手抖、容易累、体重下降”),系统会:
- 从预先索引的医学知识库(梅奥诊所、NHS、中国甲状腺疾病诊治指南等)中检索最相关的段落
- 用LLM生成一份结构化的分析报告,包括:可能的疾病方向、建议就诊科室、需要做的检查、需要向医生提出的问题
- 明确附上免责声明和就医建议
技术选型及理由
| 模块 | 技术 | 原因 |
|---|---|---|
| 前端框架 | Next.js 15 App Router | SSG + 流式渲染,适合部署到Vercel,零运维 |
| 向量数据库 | 内存中的 @xata-io/pgvector 或者 chromadb 客户端 |
小规模演示不需要额外服务,内存即可 |
| Embedding | OpenAI text-embedding-3-small |
1536维,性价比高,单次embedding成本约0.00002美元 |
| LLM | OpenAI GPT-4o-mini | 成本低(百万token约0.15美元),流式输出体验好 |
| 知识库 | 手动整理的Markdown文件(来自公开医学指南) | 可控、无版权风险,且可以按需定制 |
为什么不直接用GPT-4本身? 纯LLM回答医疗问题容易出现幻觉,而且没有来源引用。RAG确保每个回答都锚定在可信文本上,这在医疗场景是底线。
核心代码实现
1. 构建知识库索引
将医学指南分割成chunks,生成embedding并存储。我用了一个简单的JSON文件作为向量存储(生产环境请用pgvector或Qdrant)。
// lib/embeddings.ts
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function getEmbedding(text: string): Promise<number[]> {
const res = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return res.data[0].embedding;
}
// lib/indexer.ts
import { getEmbedding } from './embeddings';
import fs from 'fs/promises';
interface Chunk {
text: string;
source: string;
embedding: number[];
}
const CHUNK_SIZE = 500; // tokens
export async function indexDocument(filePath: string) {
const content = await fs.readFile(filePath, 'utf-8');
// 简单按段落分割,更精确可用langchain文本分割器
const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 50);
const chunks: Chunk[] = [];
for (const para of paragraphs) {
const embedding = await getEmbedding(para);
chunks.push({ text: para, source: filePath, embedding });
}
await fs.writeFile('data/index.json', JSON.stringify(chunks, null, 2));
console.log(`Indexed ${chunks.length} chunks.`);
}
实际运行时,我预先索引了以下来源:
- 梅奥诊所 Graves 病页面
- NHS 甲状腺功能亢进指南
- 中国《甲状腺功能亢进症诊治指南》(2022)
- 美国甲状腺协会患者教育材料
2. 查询时检索
// lib/search.ts
import { getEmbedding } from './embeddings';
import indexData from '@/data/index.json';
// 余弦相似度
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
export async function searchSimilar(query: string, topK = 5) {
const queryEmbedding = await getEmbedding(query);
const scored = indexData.map(chunk => ({
...chunk,
score: cosineSimilarity(queryEmbedding, chunk.embedding),
}));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, topK);
}
3. 生成最终回答(流式输出)
// app/api/analyze/route.ts
import { searchSimilar } from '@/lib/search';
import OpenAI from 'openai';
export async function POST(req: Request) {
const { symptoms } = await req.json();
if (!symptoms || typeof symptoms !== 'string') {
return new Response('Missing symptoms', { status: 400 });
}
const relevantChunks = await searchSimilar(symptoms);
const context = relevantChunks.map(c => c.text).join('\n\n---\n\n');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const systemPrompt = `你是一个基于权威医学指南的辅助分析工具。
用户会描述他们的症状。你将从以下知识库中检索到的信息出发,提供:
1. 可能的疾病方向(明确说明这只是可能性,不是诊断)
2. 建议就诊科室
3. 可以做的检查
4. 需要向医生提出的关键问题
始终包含免责声明:"我是AI,不能替代医生诊断。请及时就医。"
知识库上下文:
${context}`;
const stream = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `我的症状是:${symptoms}` },
],
stream: true,
});
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) controller.enqueue(new TextEncoder().encode(content));
}
controller.close();
},
}),
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
);
}
前端直接用fetch + ReadableStream渲染,代码就不贴了,GitHub仓库里有完整的React Server Components实现。
项目结构
symptom-analyzer/
├── app/
│ ├── page.tsx # 主页面(输入框)
│ ├── result/ # 结果页(流式渲染)
│ └── api/analyze/route.ts
├── lib/
│ ├── embeddings.ts
│ ├── indexer.ts
│ └── search.ts
├── data/
│ ├── index.json # 预生成的向量索引(约5MB)
│ └── sources/ # 原始Markdown文件
├── public/
├── next.config.js
├── package.json
└── vercel.json
部署到Vercel时,直接将 data/index.json 作为静态资源保留下。由于向量索引是本地JSON,不需要数据库,所以冷启动也很快(约200ms)。
上线要踩的坑
1. 免责声明必须显眼,且不可被用户跳过
我参考了Google Health的实践:在结果顶部用红色警示框显示“此工具不能替代医生诊断”。同时API返回的文本开头强制插入免责声明(即使LLM有时会忘记)。
2. 医疗知识库的时效性
Graves病的诊疗指南每年都有小更新。建议设置一个 lastUpdated 字段,同时在UI上显示“知识库更新时间:2026年4月”。当知识库太旧时(比如超过1年),主动提示用户信息可能过时。
3. Embedding + 流式输出的组合问题
OpenAI的流式输出分块可能包含不完整的标记。如果前端直接显示,部分内容可能被截断。我的做法是:不在前端做额外处理,让 ReadableStream 直接输出文本片段,浏览器会自然渲染。但如果想高亮引用的来源,则需要后端先组装完整句子再返回,但这会牺牲实时性。我选择牺牲实时性——医疗场景准确性优先。
4. 并发与速率限制
免费OpenAI账号有每分钟500次embedding的限制,而每个查询需要一次embedding + 一次LLM调用。如果一天几千次查询,embedding容易打满。我的方案:对常见症状组合(如“心悸手抖”)做embedding缓存,用Redis或Vercel KV存储。命中率约30%,能降低约三分之一的embedding调用。
5. 不要返回“确诊”结论
哪怕知识库说“Graves病最常见的症状包括……”,LLM也可能输出“您可能患有Graves病”。我在system prompt里加了一条强约束:只能使用“可能与……相关”“建议排查”等表述,严禁出现“您患有”字样。并用few-shot示例强化。
这工具真的能帮到患者吗?
Erin Moriarty 的故事里有一个细节:她对着镜子看到自己眼球突出,但直到内分泌科医生发邮件说“我们有答案了”才真正知道是Graves病。如果她早几个月用这个工具,输入“眼突+心悸+体重下降”,AI会立刻检索到Graves病是高度相关的可能,并建议她看内分泌科做TSH受体抗体检测。这可能会缩短确诊周期——当然,前提是她能及时就医。
我不是在宣称AI能解决一切,但把权威信息以个人化、易理解的方式呈现,本身就是一种赋权。开发者应该关注的是:如何让LLM在约束下提供可靠信息,而不是让它自由发挥。 本文的代码虽然简单,但核心机制(检索+约束生成)是任何专业领域AI助手的基础。
你可以把这个模板套用到法律咨询、营养建议、宠物健康——工程模式是一样的,变化的是知识库和约束规则。祝你的第一个AI医疗助手早日上线。
所有代码可在 [github.com/yeqingyuan/symptom-analyzer] 获取。