交付出件箱

← 返回出件箱

stage2批5·门店端「赵阳智脑分析」调优 · 改动前风险预核(2026-09-03)

背景

计划对 app/services/refractive_record_service.py(门店端视光档案·赵阳智脑分析,函数
generate_record_analysis / build_ai_comparison)做四件改动:

  1. prompt 去掉 citations 输出要求 —— 拟给 build_summary_messages 加 scene 参数分流,秒读走原样
  2. 接知识库 RAG(现在 kb_chunks=[] 硬编码)
  3. build_ai_comparison「跟上次比」的规则拼句 → 改成 27B 生成
  4. 历次对比加时间窗(近半年/近一年/全部,最多 8 条)

长官要求:不得影响任何现有调 AI/27B 的功能(秒读、AI 问答、眼导航、相似病例、社区等)。

本报告只做只读调查(grep + 局部真跑 pytest),不改任何仓库文件。

一句话总结

四件改动风险都可控,但②③两件各自撞上代码里已经写明的「需长官另行拍板」红线注释 ——
不是技术做不到,是这两处此前的开发者已经明确判定「超出范围,需要长官点头」,现在长官既然
拍板要做,只是提醒别漏看这两条历史备注、别把它们当技术债误删。①④两件是纯增量,风险低。

风险总表

维度 结论 一句话
维1 27B 调用方 🟢 15 处 VLLMClient() 各自建实例,无单例/无共享状态,改一处不影响其他调用方
维2 VLLMClient 共享状态 🟢 类里 0 单例痕迹(_instance/lru_cache/global 全 0 命中),纯无状态包装类
维3 build_summary_messages 调用方 🟢 仅 2 处真调:four_l_summary_service.py:226(秒读,禁动)+ refractive_record_service.py:1133(本次目标)
维4 prompt citations 段 🟡 防幻觉约束句(不准凭训练知识自由发挥)跟 citations 段耦合在同一个 header 常量里,删 citations 若连带删了这句会伤秒读质量
维5 citations 消费方 🟢 门店端解析(generate_record_analysis)从不读 citations 字段;前端类型/组件也不含 citations
维6 秒读 vs 视光档案 JSON 解析 🟢 两处平行独立解析函数,视光档案那份天然不摸 citations
维7 KnowledgeRAGService 状态 🟡 get_embedding_service() 是模块级 global 单例(embedding_service.py:125),接 RAG 会让本 scene 新增一路并发方,但已有 4 个调用方共用同一单例,非新增风险类型
维8 build_ai_comparison 调用方 🟡 单一后端调用点(refractive_records.py:281)+ 单一前端消费点,但文档原话写死"避免GET接口再叠一次27B延迟...如需改为LLM叙述需长官另行拍板"
维9 聊天链路独立性 🟢 h5_chat.py 跟四个目标符号交集 0 命中,对照组(直连 VLLMClient)也 0 命中——本就走 BrainGateway,物理不共享代码
维10 眼导航/相似病例/社区/编排 🟢 全部只碰 compute_summary_hint 或独立 VLLMClient()/KnowledgeRAGService(),0 处碰 build_summary_messages
维11 测试基线 🟡 抽跑 4 个相关测试文件:2 failed, 54 passed(失败的是要连真 27B+真PG 的用例,属于本云机环境基线红,非本次改动引入)
维12 前端 parseAiAnalysis 🟢 校验字段固定 p1_diag/p2_advice/p3_warning/plan_4l/plan_drugs_plus,类型定义里完全没有 citations 字段
维13 附·历史注释 🟡 refractive_record_service.py:449-452 原话:"KB A检索本次未接入...如需补做需长官另行拍板"——②正是要补做这条

逐维证据

维1 · 27B 全部调用方(raw 3-15行、34行)

app/services/refractive_record_service.py:1135:    resp = await VLLMClient().generate_chat(
app/services/four_l_orchestrator.py:712:                        summary_db, VLLMClient(),
app/services/vision_ocr_service.py:118:        vllm = VLLMClient()
app/services/community_service.py:30:        self.vllm_client = VLLMClient()
app/services/brain_gateway.py:127:        self.vllm_client = VLLMClient()
app/services/brain_gateway.py:1621:            self._vllm_high_level = VLLMClient()
app/services/query_rewrite_service.py:59:        result = await VLLMClient().generate_chat(
app/api/four_l_report.py:157/549/581: vllm = VLLMClient() / FourLSummaryService(db, VLLMClient(), ...)
app/api/refractive_records.py:379:            data = await VLLMClient().extract(str(path), kind)
app/api/parent_ocr.py:103:            data = await VLLMClient().extract(str(path), kind)

对照组:VLLMClient 全仓命中数 = 37(比 15 处实例化多,含 import/注释/异常类引用,查法有效)。
结论:每个调用方各自 VLLMClient() 新建实例,彼此不共享调用状态。

维2 · VLLMClient 共享状态(raw 37-121行)

app/services/vllm_client.py 是纯异常体系 + 无状态 client 包装(httpx 每次调用建连接)。
_instance|lru_cache|^_client|global |singleton 关键字 0 命中;对照组 class VLLMClient 命中 1 次
(证明查法生效,不是语法错)。结论:该类没有跨请求共享状态,可以放心让新调用方(视光档案)
携带不同 prompt/参数,不影响其它调用方。

维3 · build_summary_messages / compute_summary_hint 调用方(raw 123-159行)

def build_summary_messages(
    hint: SummaryHint,
    kb_chunks: list[dict] | None = None,
) -> list[dict]:

真正调用 build_summary_messages(...) 只有两处:
- app/services/four_l_summary_service.py:226(秒读引擎本体,不能动)
- app/services/refractive_record_service.py:1133(本次目标)

compute_summary_hint 被 eye_nav.py:133/221、four_l_summary_service.py:213、
refractive_record_service.py:1132 调用,但 eye_nav.py/find_similar_service.py
只用 compute_summary_hint,不碰 build_summary_messages(维10 逐文件验证 0 命中)。

⚠️ 唯一未查全的点:rumor_question_screening.py:45 有 from app.services.four_l_summary_prompt import (
多行 import,raw 输出未展开括号内具体符号列表 —— 建议实施前单独 sed -n '45,55p' app/services/rumor_question_screening.py
确认它是否也调 build_summary_messages(目前证据不足以下结论,不排除有第 3 个调用方)。

维4 · prompt 里 citations 段(raw 170-268行)

KB_A_CITATIONS_SECTION_HEADER: str = (
    "【KB A 引文 grounding · 严守】\n"
    "- 以下是知识库 A 检索结果(来自赵阳博士权威 4L 疗法资料 + 临床案例)\n"
    "- 每条 L1-L4 建议(p2_advice)必须基于这些引文 · 不准凭训练知识自由发挥\n"
    "- 输出 citations 字段必填:每条 {text, source_file}\n"
    "- 若 KB 检索 0 命中 · 输出 citations: [] · p2_advice 注明'暂无 KB A 出处·建议线下医生确认'\n"
)

拼装处(line 474):f"{KB_A_CITATIONS_SECTION_HEADER}\n" f"{_format_kb_chunks(kb_chunks)}\n\n"。
输出格式段(line 476/488/490/491)明确写死 "7 个顶层 key"、plan_4l 内部只有 4 个 key、
citations 是第 7 个 key。防幻觉约束句 不准凭训练知识自由发挥 就嵌在 header 常量第 3 行,
_format_kb_chunks 0 命中分支(line 326)还有一句 27B 不准基于训练知识编 L1-L4 建议。

红线:这两句防幻觉约束是给"有 KB 检索结果时约束 27B 别瞎编"用的,跟 citations 字段是否
输出是耦合在同一段文本里的。如果新 scene 要去掉 citations 输出要求,不能把整个
KB_A_CITATIONS_SECTION_HEADER 一起拿掉
—— 秒读那边(scene 默认值)必须继续拿到这段文本,
否则等于顺手削弱了秒读的防幻觉约束(超出本次范围的隐性回归)。正确做法是只在新 scene 分支
跳过"7 key → 6 key"的输出格式声明和 header 拼接,不影响默认分支的文本。

维5 · citations 消费方(raw 269-326行)

后端:four_l_summary_service.py 的 SummaryResult.citations(line 94/319)从 27B 输出 JSON 解析
(line 295 raw_citations = parsed.get("citations", []) or []);另有 _build_citations
方法(line 500-538)用 kb_chunks 重新构造、并显式注释"忽略27B输出的result.citations·因为
27B没source_url"(line 506)—— 即最终落库/推给前端的 citations 其实不是直接用 27B 吐的那份。

前端:web/app/h5/eye-nav/lib/types.ts:35 有类型声明 citations: SummaryCitation[];
four-l-detail-sheet.tsx:1180-1182 注释写明"底部'赵阳智脑论证溯源'citations块 · 长官5-27拍
删 · summary_json.citations 后端数据保留 · 仅前端不渲染"——citations 目前前端已不渲染,
只是数据留着
。

结论:视光档案(generate_record_analysis)从头到尾没有解析或使用 citations 字段
(见维6),去掉它不影响任何现有下游。

维6 · 秒读 vs 视光档案 JSON 解析(raw 361-506行)

refractive_record_service.py:1139-1164 的解析只提取 p1_diag/p2_advice/p3_warning/plan_4l/ plan_drugs_plus,不含 citations;result = {...} 字典(line 497-505)本身也没有 citations
键。跟 four_l_summary_service.py 那份平行但独立的解析逻辑(line 279-344,含 citations)物理
分离,互不干扰。

维7 · KnowledgeRAGService 状态(raw 512-599行)

调用方 8 处(four_l_orchestrator.py/community_service.py/brain_gateway.py/
four_l_summary_service.py(可选注入)/four_l_report.py/vision_exams.py)。构造函数:

def __init__(self, db: AsyncSession, ollama_base_url: str = "http://localhost:11434"):
    self.db = db
    self.embedding_service = get_embedding_service()

get_embedding_service() 定位在 embedding_service.py:125,grep 命中 global _embedding_service
—— 是模块级全局单例。Semaphore|pool|max_connections|timeout 关键字只命中两处 httpx
timeout=300.0/timeout=120.0,没有并发信号量限流。

结论:接入 RAG(改动②)会让门店端这条新链路复用已有的全局 embedding 单例,增加一路
并发调用方,但这是"新增一个使用者",不是"引入新的资源竞争模式"——现有 4 个调用方已经在共用
同一单例,风险属于量变不是质变。真正的红线在维13:这条链路此前被显式标注"本次未接入,
需长官另行拍板"。

维8 · build_ai_comparison 调用方(raw 601-740行)

后端唯一调用点:app/api/refractive_records.py:281
result["ai_comparison"] = await svc.build_ai_comparison(db, org_id, rec, eyes)。
前端唯一消费点:frame4-record-view.tsx:472 getComparison(record.id, "latest"),注释写明
"复用既有getComparison()(scope参数对ai_comparison无影响,取最轻量latest)"。

现有实现(line 653-740)是纯规则拼句,函数 docstring 原话:

"叙述文本为规则化拼句(非二次LLM调用):...避免GET接口再叠一次27B延迟,也避免自由生成叙述
带来的瞎编风险。如需改为LLM自然语言叙述,需长官另行拍板。"

baseline = history[-1] 硬编码取历史最近一条(非窗口化),跟维8 前端另一套 getComparison(..., "last6"/"all",{limit:20}) 的多窗口逻辑是两套并行机制(comparison-panel 主表格用 scope 窗口,
ai_comparison 卡片用固定"最近一条")。

结论:改动③把这条规则拼句换成 27B 生成,是直接推翻代码里已经写明的"故意不用 LLM"的设计
决定,技术上可行(单一调用点、单一消费点、改起来干净),但这不是"删代码 bug"是"翻已有拍板"——
既然长官这次亲自要求改,只是要留痕:这处改动等于覆盖了此前"避免GET接口叠加27B延迟"的顾虑,
建议把 comparison 27B 调用做成 fire-and-forget/异步预生成或加缓存,不要让 GET
/records/{id}/comparison 同步等 27B 响应,否则会引入新的接口延迟(现状 0 延迟 → 加一次
27B round-trip)。

维9 · 聊天链路独立性(raw 750-773行)

app/api/h5_chat.py import 列表跟 four_l_summary_prompt|refractive_record_service| build_summary_messages|knowledge_rag_service 交集 0 命中;对照组(该文件直连
VLLMClient|vllm|generate_chat)同样 0 命中——查法有效,证明 h5_chat.py 物理上不共享
这批代码,走的是 BrainGateway(line 20 from app.services.brain_gateway import BrainGateway)。
结论:本次四项改动不可能触达聊天链路。

维10 · 眼导航/相似病例/社区/4L编排逐文件核查(raw 775-804行)

文件 碰 build_summary_messages? 碰 refractive_record_service/knowledge_rag_service?
eye_nav.py 0(只用 compute_summary_hint) 0
find_similar_service.py 0(只 import SummaryHint 类型) 0
community_service.py 0 自建 VLLMClient()+KnowledgeRAGService(),独立实例
four_l_orchestrator.py 0(通过 four_l_summary_service 间接) 自建 KnowledgeRAGService,独立实例
four_l_report.py 0(通过 FourLSummaryService 间接) 自建 KnowledgeRAGService,独立实例

结论:四个模块全部要么完全不碰目标符号,要么用独立实例碰 VLLMClient/
KnowledgeRAGService(各自 __init__ 传参,不共享单例状态,唯一共享的全局态是维7 提到的
embedding_service),没有一条路径会因为改 build_summary_messages 签名或
refractive_record_service.py 内部逻辑而被波及。

维11 · 测试基线真跑(raw 806-875行)

相关测试文件清单(ls tests/** 过滤后 34 个),抽跑 4 个直接相关的:

将跑: tests/contract/test_batch5_ai_analysis_4l.py tests/contract/test_batch3_binding.py
      tests/unit/test_four_l_summary_prompt.py tests/unit/test_four_l_summary_service.py
...
FAILED tests/unit/test_four_l_summary_service.py::test_success_real_27b_real_pg
FAILED tests/unit/test_four_l_summary_service.py::test_quality_check_failed_axial_trend_recede
2 failed, 54 passed, 9 warnings in 10.24s

这是改动前的基线,失败的两个用例名字都带 real_27b/real_pg,大概率是本云机环境连不上
真 27B/真 PG(项目铁律"no-mock e2e 真打"要求真连,云机可能没有可达的 27B 端点)导致,不是
本次调查引入的新问题。改动后复核必须以"54 passed 基线不倒退"为准,而不是苛求 2 个失败项也
转绿(那两项失败原因跟本次改动无关,需另查环境连通性)。

维12 · 前端 parseAiAnalysis(raw 877-940行)

interface Parsed4LAnalysis {
  p1_diag: string;
  p2_advice: string;
  p3_warning: string;
  plan_4l: Plan4L | null;
  plan_drugs_plus: string;
}
function parseAiAnalysis(text: string): Parsed4LAnalysis | null {
  try {
    const obj = JSON.parse(text) as Record<string, unknown>;
    if (!obj || typeof obj !== "object"
      || typeof obj.p1_diag !== "string"
      || typeof obj.p2_advice !== "string"
      || typeof obj.p3_warning !== "string") { return null; }
    ...
  } catch { return null; }
}

类型定义和校验逻辑里完全没有 citations,只强制 p1_diag/p2_advice/p3_warning 三个必填
字符串 + 可选 plan_4l/plan_drugs_plus。结论:去掉 citations 输出对前端 0 影响
(前端本来就没检查它、也没渲染它,维5 已确认)。

维13(附) · 视光档案历史注释(raw 945-982行)

KB A 检索(FAISS grounding)本次未接入:build_summary_messages 显式支持
kb_chunks=[] 走老逻辑(该函数 docstring:"None 或 [] 时走老逻辑(0 KB grounding ·
向后兼容)"),接入需要 FAISSService/KnowledgeRAGService 依赖注入,超出本次
"结构化 4L 输出"范围,如需补做需长官另行拍板。

以及批 5 顶部大注释(line 968-981):"9-03 长官打回改造:AI分析不再是纯文本对话·复用报告
秒读(4L)引擎...仍不消耗秒读额度(不调 org_service.record_usage·只是复用引擎代码,不复用
额度计费)"、"6类触发重算的临床字段(视力/验光/眼轴/角膜曲率/眼压/瞳距)"。

结论:这条注释就是改动②要补的那个口子,措辞跟本次任务描述完全对得上,不是要新造能力,
是把此前显式搁置的 TODO 兑现。

改动方案修正建议(带 file:line)

① prompt 去 citations —— scene 参数方案

② 接知识库 RAG

③ build_ai_comparison 规则拼句 → 27B 生成

④ 历次对比时间窗(近半年/近一年/全部,最多 8 条)

改后复核清单

  1. 基线数字(本次已测得,复核时对比):2 failed, 54 passed(4 个测试文件,10.24s)——
    改动后重跑同样 4 个文件,新增失败数必须为 0(那 2 个 real_27b_real_pg 失败项若环境
    仍连不上 27B/PG 可以维持失败,但不能新增其他失败)。
  2. 追加验证 rumor_question_screening.py:45 的 from app.services.four_l_summary_prompt import (...) 具体导入了哪些符号,排除维3标记的未查全风险。
  3. scene 参数落地后,跑 grep -n "build_summary_messages(" app/ -r 确认全仓仍然只有
    2 个调用点且默认分支(秒读)调用签名字符不变。
  4. RAG 接入后,grep -n "KnowledgeRAGService(" app/services/refractive_record_service.py
    确认是通过参数注入而非函数体内部无条件实例化。
  5. build_ai_comparison 改造后,跑 tests/contract/test_batch5_ai_analysis_4l.py
  6. 手动 curl /records/{id}/comparison 确认响应耗时(判断③是否引入了明显延迟)。
  7. 前端跑 web/__tests__/h5-family-source-badge.test.tsx 及 frame4-record-view.tsx
    相关的现有 vitest(若有),确认 parseAiAnalysis 未因新增字段而回退到 null 分支。

云机环境·非验收证据