前一阵子孩子半夜突然发烧全家的第一反应不是去医院而是打开手机开始查发烧能不能吃布洛芬、什么度数该去医院、附近哪家医院夜里还有儿科急诊。结果查了半小时信息越查越乱反而更慌。那一刻我突然意识到大部分人需要的不是一个搜索引擎而是一个能把零散信息整理成清晰建议的“健康助理”。这个项目就是冲着这个需求去的。我基于大语言模型LLM做了一套医疗AI Agent让它能听懂自然语言调用分诊建议、药品查询、医院导航这些工具最后输出带依据、带风险提示的回答。整套系统包括后端Agent核心循环、工具函数集、以及一个适配手机端的聊天界面代码全部可以跑通部署到云服务器上就能用。需要说明的是这套系统定位是“健康信息助手”不是“在线医生”它能帮你整理信息、做初步分诊建议但绝不能替代线下诊断。这篇文章我会从技术选型、Agent架构、核心代码、踩坑实录四个部分完整拆解适合正在学大模型应用开发、想搞懂Agent原理或者想给家里老人小孩做一个实用健康助手的开发者。看完你不仅能复现还能明白每一步为什么要这么做。1. 项目定位与技术选型1.1 为什么是“Agent”而不是“问答机器人”如果只是想做一个医疗知识问答机器人直接调大模型API写一句“你是医疗健康助手”然后丢几个FAQ文档进去就够了。但实际做下来你会发现纯问答模式有三个硬伤一是模型幻觉。你问“发烧到38.5度怎么办”模型可能凭训练记忆输出一段话但你不知道它依据的是哪本指南也不知道是哪个科室给出的建议。医疗场景容错率极低这种“无据可依”的回答基本不敢用。二是没有行动能力。用户问“附近哪里有急诊”模型答不出实时位置因为它不会调用地图API。用户问“这个药一天吃几次”模型如果没被灌入说明书数据就只能瞎编。三是没有状态管理。多轮对话中用户可能先说“孩子3岁”再问“发烧怎么办”普通问答机器人无法把这些信息关联起来导致每个问题都孤立地重新回答一遍前后甚至可能矛盾。Agent恰好解决这几个问题。它的核心是把大模型当成“大脑”把工具函数当成“手”通过一次次的“规划—调用—观察”循环让模型回答问题前先去查证、先拿到数据、再组织语言。整套流程有点像一个真正的人先听清楚问题再翻资料、量体温、查地图最后给建议。1.2 技术栈选型为什么用DeepSeek Flask模型选型是第一个纠结的点。我列了三个候选OpenAI的GPT-4系列、DeepSeek、以及开源可私有化部署的Qwen系列。最终选的是DeepSeek原因很直接支持Function Calling且中文医疗场景表现扎实API价格相对友好个人部署不会肉疼上下文窗口足够大能容纳多轮对话和工具返回结果。如果你不想用DeepSeek代码里所有调用大模型的逻辑都被封装在一个函数里换成OpenAI或者本地跑的Qwen只需要改API地址、模型名和鉴权方式其余逻辑不用动。这句话不是客套而是我在重构时特意留的接口本来就被设计成可插拔的。服务端框架选了Flask没有上FastAPI。原因也很朴素这个项目场景是个人部署、低并发、单机运行Flask足够轻部署时一行命令就能起服务不需要搞Uvicorn、Pydantic那一堆东西。当然后面如果要做流式输出、WebSocket、高并发还是建议切FastAPI这部分我在第4章会展开讲。前端我没有用任何框架直接写了一个单HTML文件内置聊天窗口、快捷按钮、输入框通过fetch向后端接口请求数据。这样做的好处是零构建传上服务器就能访问手机浏览器打开就是移动端布局不需要适配小程序或者App。1.3 医疗场景的合规边界要提前想清楚技术选型之前有一个更重要的功课要先做完这个助手到底能做什么不能做什么。我的边界设计是三个原则第一只做“信息整理与分诊建议”不做“疾病诊断”。任何涉及诊断式的表述模型都会被system prompt强制加上“无法确诊请尽快就医”的提示。第二用药建议只做“药品说明书信息查询”。用户问“阿莫西林是干嘛的”助手按药物说明书的结构化内容回答比如适应症、用法用量、禁忌症但不会主动推荐“你应该吃阿莫西林”。如果检测到用户描述与“乱用药”相关会优先进行风险提示。第三急重症关键词必须走“响应升级”。当输入中出现“胸痛、呼吸困难、意识模糊、大量出血”这类高危词时Agent不再绕弯子直接建议拨打急救电话或立刻去急诊。这套边界不是写在文档里就完了我把它全部固化在Prompt和工具函数里从机制上拦住风险。换句话说哪怕模型某次出现了幻觉工具层也能兜底。2. Agent架构核心细节拆解2.1 四要素设计规划、工具、记忆、反思Agent不是一个大模型调接口那么简单我在项目里把它的内核拆成了四个维度分别对应代码里的不同模块规划Planning用户输入进来后模型先判断需要调用哪些工具以及调用的先后顺序。比如“孩子发烧39度家里有布洛芬该不该吃”模型规划出来的路径是先走分诊建议工具判断有没有急诊指征再走药品查询工具看看布洛芬的适用年龄和剂量最后汇总成一段包含风险提示的回答。工具Tools这是Agent的行动接口。我设计了五个工具函数分别是分诊建议、药品说明书查询、医院导航、健康科普检索、病情记录存储。每一个都被描述成结构化JSON Schema方便模型在Function Calling时准确传入参数。记忆Memory用户说过的关键信息比如年龄、过敏史、过往症状会被抽取出来存进对话上下文里。我用的是单会话内的滑动窗口窗口满了以后自动保留最关键的信息丢弃不重要的历史细节防止上下文过长导致回答质量下降。反思Reflection这是很多人忽略的一点。Agent输出最终回答前我会要求模型做一道自查工序确认回答是否覆盖了用户的核心诉求是否有风险提示是否有依据。这个“反思”步骤让模型自己给自己纠错实际测试下来回答的完整度提升非常明显。2.2 工具定义Function Calling的Schema实践DeepSeek对Function Calling的支持方式是让模型输出一个结构化调用请求后端解析这个请求执行函数再把结果塞回对话里。核心在于工具描述要写得足够清晰。给一个我自己在用的工具定义示例{ type: function, function: { name: triage_advice, description: 根据症状和用户基本信息给出分诊建议是否需要立即就医、建议挂什么科室、家庭护理注意点。注意该工具只做建议不做诊断。, parameters: { type: object, properties: { symptoms: { type: string, description: 用户描述的主要症状如发热、咳嗽、头痛 }, duration: { type: string, description: 持续时间如2小时、3天 }, age_group: { type: string, enum: [儿童, 成年人, 老年人, 孕妇], description: 患者年龄段 }, emergency_keywords: { type: array, items: {type: string}, description: 用户描述中的急重症关键词如胸痛、呼吸困难、昏迷 } }, required: [symptoms, duration, age_group] } } }这里有一个细节坑description字段千万不要写得含糊。模型是根据字段描述来填参数的如果“duration”你只写“时间”模型可能填一个“几天”这样的模糊值后端函数解析就崩了。一定要写明格式和取值范围比如“用小时或天表示如2小时、3天”。2.3 提示词工程医疗场景的System Prompt怎么设计系统提示词是医疗Agent的生命线。我的System Prompt不是一段话而是按层级组织的“指令包”包含角色设定、边界声明、工具使用原则、回答结构模板、安全兜底规则五大部分。核心内容如下你是“健康助手”一个面向家庭用户的智能健康信息助理。 你的定位是帮助用户整理健康信息、理解症状含义、提供分诊建议方向、查询药品说明书。你不是医生不具备诊断资质你的所有回答都应当引导用户在必要时就医。 你必须遵守以下规则 1. 当用户描述的症状涉及胸痛、呼吸困难、意识不清、持续大出血、严重过敏反应时你必须立即建议拨打120或去急诊不要给出任何保守观察建议。 2. 当你调用药品查询工具后必须核对用户年龄段是否在该药品的适用范围内超出范围时明确提示禁忌。 3. 你不得推荐任何处方药的具体用药方案。你只解释药品说明书中的既有内容。 4. 回答末尾必须附带提示本回答不能替代医生诊断如有不适应及时就医。 5. 回答结构要求先用一句话概括核心建议再列出分点建议最后给出风险提示和就医时机。这套Prompt看起来简单但每一条都是踩坑踩出来的。比如第5条如果没有这个结构约束模型经常长篇大论讲一堆病理知识用户看了半天不知道下一步到底该干嘛。加了“概括—分点—提示”三段式之后回答的可操作性立刻提升。2.4 多轮对话中的记忆机制多轮对话这块我踩过一个很深的坑第一轮用户说“孩子3岁”第二轮问“发烧39度能喝布洛芬吗”如果不做记忆管理模型根本不知道“孩子”是几岁只会泛泛回答。后来我在Agent循环里加了一个“状态抽取器”每轮对话结束后用大模型抽取出用户的实体信息和关键状态比如年龄、性别、症状、用药史。这些结构化信息会回填到下一轮的上下文前缀里。def extract_user_state(messages): prompt ( 请从以下对话中抽取患者的健康档案信息包括年龄、性别、症状、持续时间、用药情况、过敏史。 如果没有某项则填null。输出JSON格式。\n\n对话内容 json.dumps(messages[-6:], ensure_asciiFalse) ) state call_llm(prompt, response_formatjson) return state抽取出来的状态会拼成一段“当前掌握的患者信息”追加到每一轮用户消息前面。这样即使对话隔了十几轮模型依然记得用户第一轮说过“孩子3岁、有青霉素过敏史”。这个机制比硬拼全量历史更省token效果也更好。3. 完整搭建过程与核心代码3.1 项目结构说明先看一下最终的项目文件结构方便你对照medical-agent/ ├── app.py # Flask应用入口 Agent循环 ├── tools.py # 工具函数集合 ├── llm_client.py # 大模型API调用封装 ├── prompt.py # System Prompt定义 ├── templates/ │ └── index.html # 手机端聊天界面 └── requirements.txt # 依赖清单整体架构很直观前端聊天气泡 → Flask后端 → Agent循环 → 工具函数 → 大模型API工具结果再回流给模型组织语言最后返回给前端。3.2 大模型客户端封装llm_client.py是整个系统的引擎我的封装逻辑是接收消息列表和工具Schema向DeepSeek发起请求返回解析后的JSON。这里要注意DeepSeek的API参数和OpenAI格式完全一致请求库直接用requests就行不需要额外装SDK。import requests import json DEEPSEEK_API_KEY sk-你的Key DEEPSEEK_API_URL https://api.deepseek.com/v1/chat/completions def call_llm_with_tools(messages, toolsNone): headers { Authorization: fBearer {DEEPSEEK_API_KEY}, Content-Type: application/json } payload { model: deepseek-chat, messages: messages, tools: tools, tool_choice: auto } resp requests.post(DEEPSEEK_API_URL, headersheaders, jsonpayload, timeout60) resp.raise_for_status() data resp.json() return data[choices][0][message]返回的message里可能包含content也可能包含tool_calls。如果包含tool_calls说明模型想调用某个工具后端要执行工具并把结果回传这个逻辑放在Agent循环里。3.3 Agent核心循环实现这是整个项目最核心的代码我贴在下面建议ETCEveryone Can Code往下看的时候一行一行吃透# app.py 核心片段 from flask import Flask, request, jsonify, render_template from llm_client import call_llm_with_tools from tools import TOOLS, TOOLS_SCHEMA from prompt import SYSTEM_PROMPT import json, uuid, copy app Flask(__name__) sessions {} def execute_tool(name, arguments): if name not in TOOLS: return {error: f未找到工具 {name}} return TOOLS[name](**arguments) def agent_run(session_id, user_input): if session_id not in sessions: sessions[session_id] [] history sessions[session_id] # 抽取用户状态拼入上下文 user_state extract_user_state(history [{role: user, content: user_input}]) context_prefix 当前掌握的患者信息 json.dumps(user_state, ensure_asciiFalse) \n messages [ {role: system, content: SYSTEM_PROMPT}, *history[-10:], {role: user, content: context_prefix user_input} ] # Agent循环最多执行5轮工具调用 for step in range(5): message call_llm_with_tools(messages, toolsTOOLS_SCHEMA) if not message.get(tool_calls): break messages.append(message) for tool_call in message[tool_calls]: func_name tool_call[function][name] func_args json.loads(tool_call[function][arguments] or {}) result execute_tool(func_name, func_args) messages.append({ role: tool, tool_call_id: tool_call[id], content: json.dumps(result, ensure_asciiFalse) }) else: return 抱歉这个问题步骤较多建议你描述得更具体一些我再帮你分析。 final_answer message[content] if message.get(content) else 我处理完了不过暂时没有找到合适的信息建议换个问法。 # 追加到会话历史 history.append({role: user, content: user_input}) history.append({role: assistant, content: final_answer}) if len(history) 20: sessions[session_id] history[-20:] return final_answer app.route(/) def index(): return render_template(index.html) app.route(/api/chat, methods[POST]) def chat(): data request.get_json() session_id data.get(session_id, str(uuid.uuid4())) user_input data.get(message, ).strip() if not user_input: return jsonify({error: 消息不能为空}), 400 answer agent_run(session_id, user_input) return jsonify({session_id: session_id, reply: answer}) if __name__ __main__: app.run(host0.0.0.0, port8000, debugTrue)这个循环的逻辑可以分为四步对应代码里的四个阶段第一步从会话或初始化中获取历史消息补上系统提示词。第二步调用模型。如果模型返回tool_calls就把这个调用消息追加到对话列表执行对应工具再把工具结果以tool角色的消息追加回去继续调用模型。第三步重复上述步骤直到模型不再请求工具调用也就是给出了最终答复。第四步把整个对话追加进历史同时做截断处理防止上下文无限膨胀。这里我设置了最多5轮工具调用上限。实际测试时大多数正常问题1到3轮就能结束设置上限是为了防止模型陷入“工具调用死循环”。这类情况偶尔会出现比如模型查完药又去查科普、查完科普又去查另一个药没有止境。3.4 工具函数集让Agent真正“能干”工具函数都写在tools.py里。分诊建议工具是核心我基于公开的急诊分诊常识做了一套简化的分级规则表。下面是简化版实现# tools.py 节选 import json, re, random def triage_advice(symptoms, duration, age_group成年人, emergency_keywordsNone): emergency_keywords emergency_keywords or [] urgent [胸痛, 呼吸困难, 意识模糊, 昏迷, 大出血, 抽搐, 窒息, 严重过敏] for kw in urgent: if kw in symptoms or kw in emergency_keywords: return { level: 立即急诊, advice: f您描述的症状包含“{kw}”属于高风险指征请立即拨打120或前往最近急诊不建议在家观察。, department: 急诊科, note: 请不要自行驾车前往尽量由他人陪同或呼叫救护车。 } if age_group 儿童 and 发热 in symptoms: return { level: 建议尽快就医, advice: 儿童发热伴随精神状态差、持续高热或反复发热建议尽快前往儿科就诊。, department: 儿科/小儿内科, note: 就诊前可先做物理降温记录体温变化避免捂汗。 } if 发热 in symptoms or 咳嗽 in symptoms: return { level: 普通门诊挂号, advice: 症状较轻可先在家休息并观察若症状持续或加重建议前往综合内科或呼吸内科。, department: 呼吸内科/综合内科, note: 多饮水、多休息体温超过38.5℃且明显不适时可考虑使用退烧药注意按说明书剂量服用。 } return { level: 普通咨询, advice: 症状描述较温和建议持续观察。如果症状加重或出现新的不适请及时就医。, department: 全科/家庭医生, note: 如果症状反复或久拖不愈建议线下进行全面检查。 }药品查询工具则从一份预置的药品结构化JSON里读取信息不对用药做建议只展示信息def drug_info(drug_name): drug_database { 布洛芬: { 适应症: 用于缓解轻至中度疼痛如头痛、关节痛、牙痛、肌肉痛、神经痛、痛经也用于普通感冒或流行性感冒引起的发热。, 用法用量: 口服。成人一次1片0.3g一日2次请间隔12小时以上儿童请按体重计算单次剂量具体遵医嘱或说明书。, 禁忌: 对本品及其他非甾体抗炎药过敏者禁用孕妇及哺乳期妇女慎用活动性消化道溃疡患者禁用。, 注意事项: 避免与其他解热镇痛药同服连续使用不得超过3天如症状未缓解请咨询医生。 }, 对乙酰氨基酚: { ... } } drug drug_database.get(drug_name.strip()) if not drug: return {found: False, message: f暂未收录{drug_name}的说明书信息请咨询医生或查看药品说明书。} return {found: True, drug: drug_name, info: drug}医院导航工具我留了一个地图API接口位实际生产环境可以接高德或腾讯地图的POI搜索给用户返回附近的医院和导航链接。本地测试时返回模拟数据做一个简单的城市医院列表。3.5 手机端聊天界面单HTML搞定前端我用了一个单HTML文件做成了手机聊天App的样式。对话区、输入框、快捷按钮一应俱全整体代码不复杂核心是通过fetch向后端/api/chat发送消息!-- templates/index.html 核心交互代码 -- div idchatBox/div div idinputBar input idmsgInput placeholder描述你的症状如发烧两天了温度38.7度 / button idsendBtn发送/button /div div idquickBar button onclickquickAsk(发烧39度怎么办)发烧如何应对/button button onclickquickAsk(布洛芬说明书)查药品/button button onclickquickAsk(需要挂什么科)科室推荐/button /div script let sessionId s_ Date.now(); async function sendMessage() { const msg document.getElementById(msgInput).value.trim(); if (!msg) return; appendMsg(user, msg); document.getElementById(msgInput).value ; const resp await fetch(/api/chat, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({session_id: sessionId, message: msg}) }); const data await resp.json(); appendMsg(assistant, data.reply); } /script手机上打开后默认就能按屏幕宽度撑满整个聊天框不需要额外的响应式适配。整套服务跑起来之后输入“孩子发烧38.5度能不能吃布洛芬”Agent会先调用分诊建议工具再调用药品查询工具最后返回一段带风险提示的完整建议。3.6 本地联调用法依赖是很简单的三件套Flask3.0.0 requests2.31.0启动命令也直接pip install -r requirements.txt python app.py启动后浏览器访问http://localhost:8000手机上同一局域网内访问http://你的局域网IP:8000即可。注意在本地测试前记得先在llm_client.py里把API Key改成你自己账号的Key。4. 常见问题与排查技巧实录4.1 模型“幻觉”严重回答没有依据怎么办这是医疗Agent最危险的问题。我实测中最典型的翻车案例是用户问“感冒了吃什么药”模型直接给出了“服用抗生素”的建议。这在医学上是完全错误的抗生素对普通病毒性感冒无效。排查发现问题出在System Prompt里没有明确约束“不得推荐处方药”。纠正方法是双管齐下一是在提示词里黑名单式列出禁止行为二是在工具层做兜底检测到模型输出包含“抗生素、阿莫西林、头孢”这些处方药名时强制追加一句警告“抗生素属于处方药必须经医生面诊后开具切勿自行服用”。另外一个有效做法是“溯源强制化”要求模型的每个结论都标注信息来源比如“根据布洛芬说明书显示”或“根据急诊分诊常识判断”。加了这层约束后模型不敢再凭空编造幻觉问题明显缓解。4.2 工具参数解析老报错模型传参不规范Function Calling模式下模型偶尔会传错参数。比如分诊工具的symptoms参数用户明明说的是“发烧三天”模型却填了symptoms: 发热三天然后工具函数内部拿这个字符串去匹配关键词列表匹配不上就返回了“普通咨询”完全错误。解决思路是参数校验。在execute_tool入口加了一个normalize_arguments函数把常见的同义词映射统一比如发热统一转成发烧拉肚子统一转成腹泻。同时每个工具函数内部也做了关键词模糊匹配用in操作符而不是判断。这套容错机制上线后工具调用成功率高了很多。4.3 多轮对话后上下文太长回答质量下降会话轮数多了以后历史消息全塞给模型token成本上升不说模型反而容易被十几轮前的干扰信息带偏。我用的是三层策略来治这个问题第一层是历史截断只保留最近10条消息。第二层是状态抽取把关键的实体信息结构化保存拼在用户消息前面。第三层是工具结果压缩工具返回的JSON如果太长比如药品说明书几百字就只保留核心字段送回给模型不把原始JSON整个塞回去。这三层配合下来即使对话持续30轮以上模型的表现依然稳定回答也没有因为上下文过长而出现逻辑混乱。4.4 并发压力与响应时间Flask自带的开发服务器处理单个请求没问题但一旦有多人同时访问就会出现排队。我把这个问题分成两个阶段解决第一阶段的低配方案把app.run改成app.run(threadedTrue)让Flask支持多线程处理请求。这个改动一行代码效果立竿见影单人家庭场景完全够用。第二阶段如果有更高并发需求就建议换FastAPI Uvicorn配合异步HTTP客户端调用大模型API性能提升是数量级的。我在项目里没有直接上FastAPI是考虑到多数读者只是想搭一套自用工具不必一开始就把架构搞复杂。4.5 安全问题与隐私保护要重视这个必须提醒一句医疗数据是高度敏感的。我在设计时做了两个基本动作一是所有对话日志只保存在内存里服务重启即清空不做任何持久化二是前端强制HTTPS如果部署到云服务器建议配一个免费SSL证书防止数据在传输过程中被截获。如果未来你要把这个系统做成多人可用的产品那还要考虑用户鉴权、数据脱敏、日志审计甚至需要咨询专业法律人士关于医疗信息合规的要求。这个项目定位为自用学习工具但合规意识必须从第一天就建立起来。最后再分享一个我这几个月实操下来最深的体会Agent的能力上限不是模型决定的而是工具层和边界设计决定的。你给模型配了十个高质量工具、写清楚了使用边界、设好了风险兜底它就真能像一个靠谱的助理一样工作反之工具写得粗糙、提示词含糊其辞哪怕用再强的模型也翻车。医疗场景尤其如此宁可回答保守一点也不能给出有风险的“确定”建议。这个项目的下一步我打算接入语音输入家里老人不用打字直接说症状就能得到建议做出来又是另一番体验。