山东大学创新实训个人博客2:基础接口与问诊智能体开发记录
在上一阶段完成了 MySQL 数据库的连通以及 FastAPI 与 Spring Boot 的双向 HTTP 通信后,这周的工作重点正式转向了基础 API 开发、和问诊智能体的开发。
一、认证与基础业务接口开发
在智能体真正开始工作之前,我们先把认证链路搭起来了。
这一步看起来像“普通后端开发”,但它决定了后面问诊是否能稳定落在“某一个真实用户”的上下文里。
我们先实现了三个最基础的接口:/auth/register、/auth/login、/auth/logout。
如果这三个接口不稳,后面的画像、会话、问诊记录都会变成无主数据,智能体也无法持续学习同一个人的历史信息。
注册接口里,前端只传用户名、手机号、密码和可选头像。
SpringBoot 接到请求后会先写 user_info,再加密密码,并初始化 health_profile 和默认数据。
POST /auth/register
Content-Type: multipart/form-data
username=张三
phone=13800138000
password=123456
avatar=(optional file)
登录接口会根据手机号和密码做校验,校验成功后生成 token。
这个 token 不只返回给客户端,还会写进 user_login_session 表,便于统一管理登录态。
POST /auth/login
Content-Type: application/x-www-form-urlencoded
phone=13800138000
password=123456
INSERT INTO user_login_session (token, user_id, issued_at, expires_at, revoked)
VALUES (?, ?, NOW(), ?, 0);
退出登录不是简单前端删 token,而是服务端把会话标记为失效。
这样即使 token 泄露或者客户端异常,后端也能主动撤销访问权限。
POST /auth/logout
Authorization: Bearer <token>
UPDATE user_login_session
SET revoked = 1, revoked_at = NOW()
WHERE token = ? AND revoked = 0;
认证链路完成之后,我们没有再为每个业务接口单独设计一套“用户识别逻辑”。
我们把 token 校验、用户加载、会话有效性检查做成统一入口,后续接口都走同一套流程。
在 SpringBoot 里,接口先从 Authorization: Bearer <token> 里提取 token。
如果请求头没有 token,也允许从参数里读取 token,这样便于调试和兼容不同客户端调用方式。
private Result<UserInfoEntity> authenticate(String authorization, String tokenParam) { String token = extractBearerToken(authorization); if (!StringUtils.hasText(token)) { token = trimToNull(tokenParam); } if (!StringUtils.hasText(token)) { return authFailure("A0405", "access token is required"); } Optional<UserLoginSessionEntity> optionalSession = userLoginSessionRepository.findByTokenAndRevokedFalse(token); if (optionalSession.isEmpty()) { return authFailure("A0416", "access token is invalid"); } UserLoginSessionEntity session = optionalSession.get(); if (session.getExpiresAt() != null && session.getExpiresAt().isBefore(LocalDateTime.now())) { return authFailure("A0417", "access token has expired"); } Optional<UserInfoEntity> optionalUser = userInfoRepository.findById(session.getUserId()); if (optionalUser.isEmpty()) { return authFailure("A0410", "user does not exist"); } return success(optionalUser.get(), "auth success"); }
/users/me 直接复用认证结果,不再让前端传 userId。
这样前端不能越权读取别人的信息,服务端只返回当前 token 对应用户的数据。
GET /users/me Authorization: Bearer <token>
/users/me/health-profile 也是同样逻辑,先认证,再用 user_id 查询画像。
如果画像不存在,服务端会按规则初始化,这样接口对前端始终稳定可用。
GET /users/me/health-profile Authorization: Bearer <token>
/sessions 列表接口会用认证得到的 user_id 查询会话。
这样即使有人猜到 sessionId,也不能通过列表接口看到其他用户会话。
GET /sessions Authorization: Bearer <token>
/session 新建会话时也依赖认证用户,不接收外部 userId。
会话创建后默认进入 CONSULTING 阶段,后续再由问诊逻辑推进阶段。
POST /session Authorization: Bearer <token> Content-Type: application/json
session.setSessionId(UUID.randomUUID().toString().replace("-", "")); session.setUserId(authResult.getData().getUserId()); session.setCurrentStage("CONSULTING"); agentSessionRepository.save(session);
除了“先认证再查数据”,我们还做了第二层保护:资源归属校验。
像 /sessions/{sessionId} 这种接口,在拿到会话后还会再判断 session.userId == auth.userId,不满足就拒绝访问。
private Optional<AgentSessionEntity> loadOwnedSession(String sessionId, Long userId) { Optional<AgentSessionEntity> optionalSession = agentSessionRepository.findById(sessionId); if (optionalSession.isEmpty()) return Optional.empty(); if (!optionalSession.get().getUserId().equals(userId)) return Optional.empty(); return optionalSession; }
这套做法的效果是,认证能力变成了系统公共能力,而不是散落在各接口里的重复代码。
后面无论是聊天接口、文件接口还是阶段推进接口,都能在同样的安全边界下工作。
二、大模型API调用开发
在问康里,大模型调用不是散在各个业务方法里的。
我们把所有模型调用集中到一个统一的 provider_clients.py,这样后面改模型、改参数、改日志,都只动一处。
这一层最先解决的是“怎么调用”,但真正关键的是“怎么稳定调用”。
所以我们在封装时做了三件事:统一客户端、统一返回结构、统一调试日志。
Qwen 和百川都按 OpenAI 兼容方式接入,调用入口保持一致。
这样业务层不关心底层供应商差异,只关心输入 prompt 和输出文本。
from openai import OpenAI def _build_qwen_client(self) -> OpenAI: return OpenAI( api_key=self.keys.aliyun_bailian_api_key, base_url=self.dashscope_openai_base_url, ) def _build_baichuan_client(self) -> OpenAI: return OpenAI( api_key=self.keys.baichuan_api_key, base_url=self.baichuan_openai_base_url, )
Qwen 文本调用用于结构化判断、报告提取这类任务。
调用后会把原始响应转字典,再统一提取文本和 JSON。
def run_qwen_prompt(self, system_prompt: str, user_prompt: str, expect_json: bool = True) -> dict[str, Any]: payload = { "model": self.aliyun_qwen_text_model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": 0.2, } completion = self._build_qwen_client().chat.completions.create(**payload) raw = completion.model_dump() text = _extract_chat_text(raw) return { "_fallback": not bool(text.strip()), "text": text, "json": parse_json_from_text(text) if expect_json else {}, "raw": raw, }
百川调用现在承担了 C1 的准备度检查和核心回复生成。
这部分保持和 Qwen 相同的返回格式,方便上层流程复用。
def run_baichuan_prompt(self, system_prompt: str, user_prompt: str, expect_json: bool = True) -> dict[str, Any]: payload = { "model": self.baichuan_model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": 0.2, } completion = self._build_baichuan_client().chat.completions.create(**payload) raw = completion.model_dump() text = _extract_chat_text(raw) return { "_fallback": not bool(text.strip()), "text": text, "json": parse_json_from_text(text) if expect_json else {}, "raw": raw, }
多模态里,症状图走 Qwen 视觉模型,语音走 Qwen ASR,处方单走百度 OCR。
它们最终都输出文本,统一进入问诊推理上下文。
def qwen_vision_to_text(self, file_url: str) -> str: payload = { "model": self.aliyun_qwen_vision_model, "messages": [ {"role": "system", "content": "提取图像中的症状相关信息并输出中文摘要。"}, { "role": "user", "content": [ {"type": "text", "text": "请识别图片中的医学关键信息并给出简洁中文总结。"}, {"type": "image_url", "image_url": {"url": file_url}}, ], }, ], } completion = self._build_qwen_client().chat.completions.create(**payload) return _extract_chat_text(completion.model_dump())
def qwen_asr_to_text(self, file_url: str) -> str: payload = {"model": self.aliyun_qwen_asr_model, "audio_url": file_url} headers = {"Authorization": f"Bearer {self.keys.aliyun_bailian_api_key}", "Content-Type": "application/json"} data = _post_json(self.qwen_asr_url, payload=payload, headers=headers, timeout=self.request_timeout) return data.get("text", "").strip()
def baidu_ocr_to_text(self, file_url: str) -> str: token_data = _post_form(self.baidu_token_url, form_data={ "grant_type": "client_credentials", "client_id": self.keys.baidu_api_key, "client_secret": self.keys.baidu_secret_key, }, headers={"Content-Type": "application/x-www-form-urlencoded"}) access_token = token_data.get("access_token") raw_bytes = _get_binary(file_url, timeout=self.request_timeout) image_b64 = base64.b64encode(raw_bytes).decode("utf-8") ocr_data = _post_form( f"{self.baidu_ocr_url}?access_token={access_token}", form_data={"image": image_b64, "language_type": "CHN_ENG", "detect_direction": "true"}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) lines = [item.get("words", "").strip() for item in ocr_data.get("words_result", []) if item.get("words")] return "\n".join(lines)
这层开发里最实用的一步,是把调用日志打到最底层。
每次请求都打印输入和原始输出,包含 URL、payload、状态码、异常体,排查时就不用猜了。
def _log_sdk_request(call_tag: str, base_url: str, payload: dict[str, Any], api_key: str) -> None: _safe_print(f"[模型SDK调用-输入] {call_tag}") _safe_print(f"[SDK BaseURL] {base_url}") _safe_print(f"[API Key] {_mask_secret(api_key)}") _safe_print(f"[SDK Payload] {_truncate_text(payload)}") def _log_sdk_response(call_tag: str, response_payload: Any) -> None: _safe_print(f"[模型SDK调用-输出] {call_tag}") _safe_print(f"[SDK 原始响应] {_truncate_text(response_payload)}")
做完这一层之后,问康智能体的模型调用就具备了三个特性。
一是调用统一,二是返回统一,三是调试可见,这也是后续稳定迭代的基础。
三、问诊智能体逻辑设计
Java端:/sessions/{sessionId}/chat是问诊智能体核心方法,具体逻辑如下:
前端点击发送按钮后调用,把消息记录和一个多模态文件数组(0或1或多个文件,最多5个,0个则该条消息为text属性)发给java端,java端先保存消息记录,如果有多模态信息,再保存多模态信息(可以复用文件上传的代码逻辑),然后调用fastapi也就是python端的方法:/consult,传输消息记录的id,触发问诊智能体分析与流式输出。
python问诊智能体核心逻辑:
先根据消息记录id查询数据库
A1.消息记录为text,跳过多模态文件处理过程
A2.消息记录不是text,进入多模态文件处理过程
多模态文件处理过程:
B1.症状图调用阿里云百炼平台的Qwen3.5-Plus模型API进行视觉图像处理
B2.语音调用阿里云百炼平台的Qwen3-ASR-FlashAPI实现语音转文字
B3.处方单调用百度智能云平台的“通用文字识别”API精准解析纸质处方单(OCR通用文字识别)多模态文件处理过程将所有多模态信息通过模型提取为文字信息,可以是Json格式,最后得到一个综合所有多模态信息的Json串(下称之为多模态文本)
紧接着,如果当前对话状态为CONSULTING,进行C1:调用百川医疗大模型API检查“多模态文本+消息记录中的文本+该用户health_profile的内容+会话历史记录总结”这四者综合来看是否达到了能够给出医疗预诊的条件(可以通过提示词来要求必须具有哪些信息)
C1.1:如果达到条件,调用百川医疗大模型API输出文本回复,并新建一份结构化的预诊报告pdf给用户,推进到下一阶段,将会话状态CONSULTING改为NAVIGATING。
C1.2:如果未达到条件,调用百川医疗大模型API输出文本回复要求用户补充缺失部分的信息。
如果当前对话状态不是CONSULTING,进行C2逻辑:调用百川医疗大模型API作为核心模型进行医学推理,根据“多模态文本+消息记录中的文本+该用户health_profile的内容”这三者综合来给出文本回复,检查外还需要再次调用Qwen3.5-Plus模型检查用户是否要求再次输出预诊报告。
结束后进行D逻辑:调用百川医疗大模型API根据“多模态文本+消息记录中的文本+该用户health_profile的内容”这三者综合来更新该用户health_profile的内容,保存下来。另外为实现多轮对话,调用百川医疗大模型API总结至今该会话的历史,保存下来。
四、问诊智能体开发过程
问诊智能体的运行不是一次函数调用,而是一条完整的处理链。
我们在开发时把它拆成 A/B/C/D 四段,每段只做一类事情,这样流程清晰,问题也更容易定位。
A.多模态请求/文本请求区分
A 段先拿到这次请求对应的核心数据。
Python 侧收到 messageId/sessionId/userId 后,不直接信任外部传入内容,而是回查数据库,取出消息、会话、健康画像和关联文件。
message = self._load_message(message_id)
session = self._load_session(session_id)
self._validate_ownership(message, session, session_id, user_id)
health_profile = self.repo.get_health_profile(user_id)
files = self.repo.list_message_files(message_id)
B.多模态信息处理
B 段做多模态处理,把图像、语音、处方单统一转换为文本。
如果本轮是纯文本消息就直接跳过,多模态才进入对应模型处理,最后统一得到 multimodal_text。
if (message.msg_type or "").upper() == "TEXT" or not files:
return [], "[]"
for file in files:
if file.file_type == "SYMPTOM_PIC":
text = self.runtime.qwen_vision_to_text(file.object_url)
elif file.file_type == "VOICE":
text = self.runtime.qwen_asr_to_text(file.object_url)
elif file.file_type == "PRESCRIPTION":
text = self.runtime.baidu_ocr_to_text(file.object_url)
B 段结束后,我们会把上下文拼成一个统一结构。
这个结构不是只含当前消息,还包含健康画像和会话历史总结,供后面推理使用。
C.问诊输出/状态判断/阶段推进
context = {
"session": {
"session_id": session.session_id,
"current_stage": (session.current_stage or "CONSULTING").upper(),
"session_context_summary": session.session_context_summary,
},
"message": {
"message_id": message.message_id,
"sender_role": message.sender_role,
"agent_type": message.agent_type,
"msg_type": message.msg_type,
"content": message.content,
},
"multimodal_items": multimodal_items,
"health_profile": health_profile.to_dict(),
}
context_json = json.dumps(context, ensure_ascii=False, indent=2)
C 段是核心推理阶段。
C1:当前会话如果在 CONSULTING,就先做预诊准备度检查,这一步由百川执行,根据我的调查,百川模型是目前市场上医疗领域的SOTA模型
readiness = self._check_consulting_readiness(
message_text=message_text,
multimodal_text=multimodal_text,
health_profile=health_profile,
session_context_summary=session_context_summary,
)
C1 里判断达标后,会继续做两件事。
第一是生成回复和预诊报告,第二是把会话阶段从 CONSULTING 推进到 NAVIGATING。
if readiness.get("is_ready"):
report_payload = self._generate_report_payload_clean(context_json)
report_url = self._generate_report_pdf(session.session_id, health_profile.user_id, report_payload)
self.repo.update_session_report_url(session.session_id, report_url)
self.repo.update_session_stage(session.session_id, "NAVIGATING")
如果 C1 判断未达标,则不会贸然预诊。
系统会输出“缺失项引导回复”,让用户补齐关键信息后再进入下一轮判断。
当前阶段如果不是 CONSULTING,则进入 C2。
C2 主要做持续推理和阶段化建议,不再执行“是否可预诊”的入口判断。
D.多轮对话/长效记忆
D 段负责把这轮智能体结果沉淀回数据库。
这里会更新 health_profile,同时生成 session_context_summary,保证下一轮推理有连续上下文。
patch = self._run_health_profile_update(context_json, health_profile)
self.repo.update_health_profile(user_id, patch)
updated_session_summary = self._run_session_context_summary_update(
previous_summary=session.session_context_summary,
context_json=context_json,
current_stage=final_stage,
agent_reply=reply,
report_generated=report_generated,
)
self.repo.update_session_context_summary(session_id, updated_session_summary)
最后系统会保存一条 AGENT 消息,并带上 agent_type。
这样后续查看聊天记录时,不只知道“谁说的”,还知道“属于哪个智能体阶段”。
agent_message_id = self.repo.save_agent_reply(
session_id=session_id,
content=reply,
agent_type=agent_type_for_message, # CONSULTING/NAVIGATING/REHABILITATING
)
五、总结
问康智能体这轮开发,真正完成的不是接了几个大模型接口,而是把一条可持续运行的问诊链路搭起来了。
这条链路把认证、会话、消息、多模态、推理、阶段推进、画像更新、会话记忆沉淀放进了同一个闭环里。
从工程角度看,最关键的收获有三点。
第一是边界清晰,SpringBoot 负责业务稳定,FastAPI 负责智能体演进。第二是状态可追踪,session_context_summary 和 agent_type 让多轮问诊有连续语义。
第三是可调试性够强,模型调用的底层 input/output 日志让问题定位不再靠猜。
这也意味着系统已经从“单轮问答”变成了“多轮、分阶段、可落库”的智能体后端。
后续继续优化 Prompt、替换模型、细化阶段策略,都可以在现有架构上平滑推进,而不需要推翻重来。
更多推荐



所有评论(0)