# 简历 · 大模型应用开发工程师
> 本文件是主版(中医知识图谱问答系统),resume 目录下另有 5 份跨行业迁移版本。
> 姓名、联系方式、学历、公司名请按实际情况替换 `<>` 占位内容。
---
## 基本信息
| | |
|---|---|
| 姓名 | `<姓名>` |
| 岗位 | 大模型应用开发工程师 / LLM Agent 开发 |
| 经验 | 4 年(其中大模型应用方向 2 年) |
| 电话 / 邮箱 | `<电话>` / `<邮箱>` |
| 学历 | `<学校>` `<专业>` `<本科/硕士>` |
---
## 技能栏
**大模型应用**
- LangGraph 有状态编排:`StateGraph` 多节点、条件边路由、带刹车的重试环、`InMemorySaver` 多轮记忆、`stream(updates)` 流式产出
- LangChain:`prompt | llm | OutputParser` 链式调用,`JsonOutputParser` 结构化输出,OpenAI 兼容端点接入(DeepSeek / vLLM 自部署)
- Prompt 工程:Few-shot、schema 注入、否定式约束(禁止改写实体、禁止生成写操作)、失败降级的多级解析
**知识图谱与检索**
- Neo4j:Cypher 读写、批量事务导入、唯一约束、`EXPLAIN` 静态校验、schema 元数据导出
- 向量检索:FAISS `IndexFlatL2`、bge-large-zh-v1.5(1024 维)、归一化向量的余弦换算、按标签过滤的召回
- 实体标准化:口语词到图谱标准词的映射("拉肚子" → "腹泻")
**模型微调**
- Alpaca 格式数据集构建(instruction/input/output),从抽取结果反向生成训练样本
- LoRA 微调 + vLLM 部署,multi-LoRA 单服务多适配器切换
**工程能力**
- Python 3.10、FastAPI(异步、`StreamingResponse`、`run_in_threadpool`、`lifespan`)、Streamlit 多页应用
- 并发:`ThreadPoolExecutor`、线程局部 Session、`asyncio.Queue` 跨线程反压
- 爬虫:BeautifulSoup 文档序遍历解析、文件系统断点续传、原子写入
- Git、Black、Windows 开发 / Linux 部署的路径兼容
---
## 项目经验
### 中医知识图谱智能问答系统 `<公司/独立项目>`
**角色**:技术负责人,独立完成全链路设计与实现,带 1 名实习生做数据校验
**背景**:中医知识散落在网页文本里,用户想问"四君子汤由哪些药材组成""我拉肚子吃什么方剂",
通用大模型答不准也答不全,会编造不存在的方剂和药材。目标是把非结构化中医文本变成可查询的
知识图谱,再让大模型基于图谱事实回答,做到答案可溯源。
**技术栈**:Python 3.10 / LangGraph / LangChain / Neo4j / FAISS / sentence-transformers /
FastAPI / Streamlit / vLLM
**七阶段流水线,逐段产出可复用的中间结果:**
**1. 数据采集**(1472 味中药 + 703 个方剂)
- BeautifulSoup 按**文档顺序**遍历 `h2/h3/p/li`,遇到标题就开新章节 —— 中药页面的子章节因药而异,
按已知字段名查找会漏掉一半内容,按顺序走才能完整保留
- 断点续传做在文件系统层:目标 `.txt` 存在即视为完成,待办列表由此推导;写入走
`<path>.<thread_id>.part` 再 `os.replace`,中断不会留下看起来完整的半截文件
- `ThreadPoolExecutor` 5 并发,`requests.Session` 按线程隔离,打印加锁
**2. 实体关系抽取**(LLM 结构化输出)
- Pydantic 定义图谱 schema 作为全链路契约:6 类实体(症状/疾病/方剂/中药/功效/出处)、
6 类关系(主治/缓解/功效/组成/伴随/出处),属性挂在实体上
- Prompt 显式要求"单实体无关系时返回空结果" —— 空结果是正确输出而非失败,
否则模型会为了填满格式而编造关系
- 成本是主要约束:单次调用 20 秒到 4 分钟,取决于**输出**规模。1472 味药串行约 90 小时,
10 并发压到 9–15 小时。失败不写文件,因此天然留在待办列表里,重跑即重试;
空结果**要写**,否则每次重跑都在为同一批无关系药材重复付费
**3. 图谱构建**(18895 节点 / 48344 关系)
- **先在内存聚合再写库**:49226 条实体记录收敛成 18895 个节点,50627 条关系记录收敛成 48344 条边
("咳嗽"出现在数百个文件里)。逐条 MERGE 等于把同样的工作重做几百遍
- **节点主键是 `(标签, 名称)` 而非名称**:356 个名称同时属于两种类型("热咳"既是疾病也是症状),
只按名称去重会把它们错误合并
- 标签、关系类型、属性键无法参数化,全部先过白名单再拼接;值一律走 `$` 参数
- 属性用 `MERGE (n:L {name:$name}) SET n += $attrs`:属性写进 MERGE 模式会因属性变化而
匹配失败并创建重复节点,`ON CREATE SET` 又永远不会回填已有节点
- 500 条一个事务,全 MERGE 所以中断后重跑即可修复
**4. 检索层**
- Neo4j 元数据导出成 schema 描述文件,注入 Cypher 生成的 prompt;三元组的中文描述由人工补齐
(含行数和用法说明),这段 prose 直接影响生成质量
- FAISS 索引覆盖全部 6 类标签的实体名,**索引本身不带标签**,所以召回后必须按标签过滤 ——
否则"咳嗽"可能召回一个药材名并落进 `matched_symptoms`,生成出毫无意义的 Cypher。
实现上超额取 `top_k * 6` 再过滤,保证过滤后仍有 top_k 个
- 7 项分级健康检查守住索引与映射的对齐:自检索(已知实体名必须以距离≈0 返回 top-1)是
真正能证明向量和映射没错位的那一项;另有与 Neo4j 的实体数对账,防止重导数据后索引变陈旧
**5. LangGraph Agent**(9 节点、2 条件边、1 个环、3 个出口)
```
START → 语义转写 → 意图识别
├─ 非中医 ─→ 直接回答 ──────────────────────→ END
└─ 中医 ─→ 实体抽取 → 实体匹配 → cypher生成 ←──┐
→ cypher检查 │
├─ 全部合法 ─→ 执行 → 图谱答案 → END
├─ 不合法且未超 3 次 ────────┘
└─ 额度用尽 ─→ 直接回答 → END
```
- **语义转写排在最前**:多轮对话里"那第二呢?"单看一句没有任何信息,意图识别会判成无关、
实体抽取一个词也抽不出。转写节点结合历史补全成完整问题,下游才有东西可用。
因此全图只有转写节点读原始 `input`,其余节点一律读转写结果 —— 这是"用户打了什么"
和"用户想问什么"的区别
- **重试环的刹车靠状态计数**:路由函数只能读 state 不能记账,所以 `cypher_retry_count`
必须由生成节点自增。上限 3 次(首次 + 2 次重试),重试时把上一轮不合法的语句回喂给模型,
否则模型看到的输入和上次完全相同,大概率原样再生成一遍
- **生成的 Cypher 直连生产库,因此在两个独立位置设闸**,prompt 里的"严禁 CREATE"不算其中之一:
① 关键词词边界扫描 + 必须有 RETURN 且以 MATCH/WITH 开头;② `EXPLAIN` 逐条静态校验,
让 Neo4j 而不是正则来当语法和标签存在性的权威
- **绝不把用户原话放进 WHERE**:用户说"拉肚子",图里存的是"腹泻"。实体匹配节点的存在意义
就是弥合这个差距,Cypher prompt 里明确禁止使用问题中的词
- JSON 解析手写多级降级:剥代码围栏 → 正则提取首个数组 → 解包信封 → 兜底抓取 MATCH 开头的行。
解析失败必须降级而不能中断整条链
- Cypher 逐条执行而非批量事务:批量的全有或全无语义会让一条坏查询废掉其余结果;
出错的位置补空列表,保持结果与语句下标对齐
**6. 服务化**
- FastAPI 三接口:健康检查、`POST /process`、`POST /process_stream`
- 图是**懒加载的模块级单例**,`lifespan` 启动时预热 —— 导入图会连带加载 76 MB FAISS 索引和
embedding 模型,约 50 秒;预热失败只记日志不阻止启动,健康检查照样可用
- `graph.invoke` 同步阻塞 5–30 秒,必须走 `run_in_threadpool`,否则事件循环被占死、
服务退化成单并发
- 流式接口用**生产者-消费者**:图的各节点在工作线程里产出,`asyncio.Queue`(有界 32,
形成反压)跨回事件循环,`StreamingResponse` 逐行吐换行分隔 JSON。
推给前端的是**状态增量**而非全量 —— 节点返回的是完整 state,直接推会让每条消息带二十多个键
- 客户端断开时取消线程任务并收敛异常,避免 "Task exception was never retrieved" 泄漏
**7. 前端与微调**
- Streamlit 多页:常规问答页 + 流式页。流式页用 `st.status` 承载思考过程,跑时展开、
跑完收起,节点状态键名做中文映射;思考过程存入 session_state 以便 rerun 后重放
- 抽取结果顺带产出 Alpaca 数据集(instruction 固定 / input 为原文 / output 为 JSON 字符串)。
两条写入路径:抽取时追加 JSONL(并发追加整行不会破坏性交错,崩溃最多损失一行),
另有不调 LLM 的幂等重建脚本 —— 追加路径只覆盖本次运行的条目,续传跳过的历史条目永远拿不到样本
- LoRA 微调 Qwen2.5 后用 vLLM 部署,multi-LoRA 单服务挂多个适配器按 model 名切换
**成果**
- 图谱规模 18895 节点 / 48344 关系,schema 6 类实体 6 类关系
- 图谱路径答案完全可溯源("四君子汤由人参、白术、茯苓、甘草四味药材组成"直接来自图谱边),
非中医提问由意图识别分流,避免无意义的图谱查询开销
- 接口层 27 条离线用例(替换假图,不触达 LLM/Neo4j/FAISS,2 秒内跑完)+ 15 条 live 用例;
前端 49 条离线 + 11 条 live,全部通过
- 单次问答端到端 3–35 秒,流式首个反馈 0.8 秒内到达
---
## 可以聊得更深的技术点
面试时这几处能展开讲设计权衡,都是项目里真实踩过的:
1. 为什么节点主键必须是 `(标签, 名称)` —— 356 个跨类型同名实体
2. FAISS 索引不带标签,为什么必须过滤而不是加大 top_k
3. 重试环的计数为什么必须由节点写而不是路由函数
4. 空结果为什么要写盘 —— 成本考量而非正确性考量
5. 流式接口为什么需要有界队列 + `.result()` 阻塞 —— 慢客户端下的反压
6. 元数据裁剪的取舍:删三元组但保留标签会让模型把关系方向写反,
生成语法合法但永远返回空的 Cypher