# 简历 · 大模型应用开发工程师

> 本文件是主版（中医知识图谱问答系统），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
