0. 系列闭环(不公开源码也能跟读)

端到端链路:Vue 前端 → api/routes/chat.py → Guide 多轮 SSE → run_analysis_pipeline(解析→分析→匹配→报告)→ tools/pdf_exporter PDF。
本篇:第 8/17 篇 · LLM 环 · 统一调用

阶段 用户可见 代码入口 对应篇
建会话 欢迎语 POST /api/sessions 09
多轮对话 SSE 流式 chat/stream → run_guide_single_turn 06, 14
信息充分 开始分析 _run_analysis_background 05, 07
履历解析 进度 30% run_resume_parser 12
画像/RIASEC 进度 50% run_profile_analyzer 03, 13
职业匹配 进度 70% run_career_matcher 02
报告 进度 90% run_reporter 11
下载 PDF 文件 GET …/report/pdf 11, 15
说明
读本篇前 第 07 篇容错背景
读完本篇 根据调用表判断该用 chat 还是 light model
下一环 第 12 篇:JSON 解析降级(第 9 篇)

全系列闭环索引:SERIES-LOOP.md

1. 要解决什么问题

iCan 有 5 个 Agent 节点 + 聊天 API,如果每个文件各自 ChatOpenAI(...)

  • 换模型(OpenAI → DeepSeek → Ollama)要改多处;
  • JSON 输出失败时各 Agent 各自 try/except,行为不一致;
  • Ollama 上的 Qwen3 会吐 `` 块,污染对话和 JSON 解析。

因此把 模型创建、超时、JSON 降级、Qwen3 处理 收敛到 llm/providers.py,Agent 只关心 messages 和返回值。


2. 实现位置与配置来源

模块 职责
config.py Settings.LLM_* 默认值与环境变量
llm/providers.py get_chat_model / get_light_model / invoke_llm / invoke_llm_with_json / check_ollama_available
llm/parsers.py parse_json_from_text(JSON 降级最后一环)

默认值(代码里,不是 .env): LLM_MODEL_CHAT=gpt-4oLLM_MODEL_LIGHT=gpt-4o-miniLLM_BASE_URL=https://api.openai.com/v1
本地或 Docker 常见覆盖:

1
2
3
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL_CHAT=deepseek-v4-flash
LLM_MODEL_LIGHT=deepseek-v4-flash

LLM 统一调用层


3. 双模型:谁用 chat,谁用 light

providers.py 注释里写 light 用于「履历解析、报告格式化」,以 grep 调用点为准

调用方 模型工厂 说明
agents/guide.py(6 处) get_chat_model() 多轮引导要稳定语气
agents/resume_parser.py get_light_model() 结构化 JSON 提取
agents/profile_analyzer.py(6 节点) get_chat_model() RIASEC + 多维度分析
agents/career_matcher.py(3 节点) get_chat_model() 路径推荐 + JSON
agents/reporter.py get_chat_model() 章节生成用 chat;文件虽 import light,当前章节节点未走 light
api/routes/chat.py get_chat_model() 单轮补全/流式

结论:只有 resume_parser 稳定走 light;不要把「Reporter 用 mini」写进文档,除非改代码。

工厂实现(节选 llm/providers.py):

1
2
3
4
5
6
7
8
9
def get_chat_model() -> ChatOpenAI:
return ChatOpenAI(
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
model=settings.LLM_MODEL_CHAT,
temperature=settings.LLM_TEMPERATURE,
max_tokens=settings.LLM_MAX_TOKENS,
request_timeout=90,
)

get_light_model()LLM_MODEL_LIGHT 不同,其余相同。


4. invoke_llm:超时与 Qwen3

1
2
3
4
async def invoke_llm(model: ChatOpenAI, messages: list, **kwargs) -> str:
processed = _inject_no_think(messages)
response = await asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
return response.content

两点:

  1. 硬超时 60swait_for),与 ChatOpenAI(request_timeout=90) 是两层超时,以 60s 先到为准。
  2. **_inject_no_think**:当 LLM_BASE_URL11434 且 model 名含 qwen3 时,在 system 前加 /no_think,避免思维链污染输出。

Guide 五个内层节点、Reporter 章节生成均走此路径。


5. invoke_llm_with_json:两阶段解析

ResumeParser、ProfileAnalyzer、CareerMatcher 需要 dict,入口是 invoke_llm_with_json

1
2
3
4
5
6
7
8
9
async def invoke_llm_with_json(model, messages, **kwargs) -> dict:
processed = _inject_no_think(messages)
try:
json_model = model.bind(response_format={"type": "json_object"})
response = await asyncio.wait_for(json_model.ainvoke(processed, **kwargs), timeout=60)
return json.loads(response.content)
except Exception:
response = await asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
return parse_json_from_text(response.content) # llm/parsers.py

顺序:JSON mode → 纯文本 + 正则/括号提取。第 12 篇专讲 parse_json_from_text 四层降级。


6. LLM 不可用时的降级(workflow 层)

workflow.pyrun_analysis_pipeline 在跑四段分析前会调 check_ollama_available()

  • 每 30s 缓存一次探测结果;
  • LLM_BASE_URL/chat/completions 发 5 token 探活;
  • 若不可用:不走 LLM,用 _regex_quick_profile + _generate_fallback_report 出规则版报告,并写入 Session.workflow_dataollama_unavailable: true)。

这是 业务层降级,不是 providers.py 内部逻辑。文档里应分开写,避免读者以为 invoke_llm 会自动 fallback。


7. 踩坑

① 注释与调用不一致
get_light_model docstring 写「报告格式化」,但 reporter.pygenerate_profile_section 用的是 get_chat_model()。改文档或改代码,二选一,不要两处矛盾。

② 默认模型不是 DeepSeek
文章标题可以提 DeepSeek 部署,正文必须写清:代码默认 OpenAI 兼容 + gpt-4o,DeepSeek 靠 .env

③ JSON mode 并非所有后端都支持
Ollama / 部分国产 API 对 response_format 支持不完整,bind 失败会 warn 并回退文本模式——生产环境要在目标 API 上实测 ResumeParser 一条链路。

④ 超时与重试
Reporter 章节生成有 2 次 attempt(见 agents/reporter.py),但 invoke_llm 本身无自动重试;Guide 节点 except 返回固定话术,不重新调模型。


8. 小结

  • LLM 层集中在 llm/providers.py,配置来自 config.Settings
  • Chat:Guide / Analyzer / Matcher / Reporter / Chat API;Light:目前主要是 ResumeParser。
  • 统一 invoke_llm(60s + Qwen3 /no_think)与 invoke_llm_with_json(JSON mode → parsers)。
  • 探活与规则报告降级在 workflow.run_analysis_pipeline,与 providers 分离。
  • 写文档以 grep 调用点 为准,不以文件头注释为准。

下一篇:SSE 流式与 WebSocket 进度(api/routes/chat.pyapi/ws_manager.py)。


附录:关键源码(逐行注释)

以下代码摘自 iCan 实现,每行上方均有中文注释,不公开仓库也可跟读。
生成命令:python3 bin/build-ican-annotated-snippets.py

get_chat_model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# ========== get_chat_model ==========
# 源文件: llm/providers.py 行 85-121

# L85: 同步函数 get_chat_model:路由决策或工厂方法
def get_chat_model() -> ChatOpenAI:
# L87: 【文档】获取对话类模型(GPT-4o)。
# L89: 【文档】功能描述:
# L90: 【文档】创建并返回一个基于 GPT-4o 的 ChatOpenAI 实例,
# L91: 【文档】用于对话引导、个人分析、职业匹配、规划生成等核心业务场景。
# L92: 【文档】模型参数从全局配置 settings 中读取。
# L94: 【文档】入参说明:
# L95: 【文档】无(从配置读取 LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_CHAT,
# L96: 【文档】LLM_TEMPERATURE, LLM_MAX_TOKENS)
# L98: 【文档】出参说明:
# L99: 【文档】ChatOpenAI: 配置好的对话类模型实例
# (L86-100 为函数/模块文档字符串,已转为注释便于阅读)
# L101: 开始 try 块,后续 except 负责兜底
try:
# L102: 记录日志,便于线上排查节点入参/出参
logger.info(
# L103: 执行该语句(细节见上文业务描述)
f"[get_chat_model] 开始执行,入参: 无,"
# L104: 执行该语句(细节见上文业务描述)
f"使用模型: {settings.LLM_MODEL_CHAT},"
# L105: 执行该语句(细节见上文业务描述)
f"BASE_URL: {settings.LLM_BASE_URL}"
# L106: 执行该语句(细节见上文业务描述)
)

# L108: LangChain OpenAI 兼容客户端;换 DeepSeek/Ollama 只改 base_url/model
model = ChatOpenAI(
# L109: 赋值:更新局部变量或 state 字段
api_key=settings.LLM_API_KEY,
# L110: 赋值:更新局部变量或 state 字段
base_url=settings.LLM_BASE_URL,
# L111: 赋值:更新局部变量或 state 字段
model=settings.LLM_MODEL_CHAT,
# L112: 赋值:更新局部变量或 state 字段
temperature=settings.LLM_TEMPERATURE,
# L113: 赋值:更新局部变量或 state 字段
max_tokens=settings.LLM_MAX_TOKENS,
# L114: 赋值:更新局部变量或 state 字段
request_timeout=90,
# L115: 执行该语句(细节见上文业务描述)
)

# L117: 记录日志,便于线上排查节点入参/出参
logger.info(
# L118: 执行该语句(细节见上文业务描述)
f"[get_chat_model] 执行完成,返回: ChatOpenAI 实例,"
# L119: 执行该语句(细节见上文业务描述)
f"模型: {settings.LLM_MODEL_CHAT}"
# L120: 执行该语句(细节见上文业务描述)
)
# L121: 返回本节点要合并进 state 的字段(LangGraph 会 merge)
return model

invoke_llm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# ========== invoke_llm ==========
# 源文件: llm/providers.py 行 171-205

# L171: 异步函数 invoke_llm:可被 await,适合 IO 型 LLM/DB 调用
async def invoke_llm(model: ChatOpenAI, messages: list, **kwargs) -> str:
# L172: 开始 try 块,后续 except 负责兜底
try:
# L173: 记录日志,便于线上排查节点入参/出参
logger.info(
# L174: 赋值:更新局部变量或 state 字段
f"[invoke_llm] 开始执行,入参: model={model.model_name},"
# L175: 执行该语句(细节见上文业务描述)
f"messages 数量: {len(messages)},kwargs: {kwargs}"
# L176: 执行该语句(细节见上文业务描述)
)
# L177: 记录日志,便于线上排查节点入参/出参
logger.debug(f"[invoke_llm] 消息详情: {messages}")

# L179: 赋值:更新局部变量或 state 字段
processed = _inject_no_think(messages)

# L181: 导入依赖模块
import asyncio
# L182: 开始 try 块,后续 except 负责兜底
try:
# L183: 硬超时包装,防止 LLM 挂死
response = await asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
# L184: 捕获异常,避免整图/整请求崩溃
except asyncio.TimeoutError:
# L185: 记录日志,便于线上排查节点入参/出参
logger.error("[invoke_llm] LLM 调用超时 (60s)")
# L186: 向上抛出异常,由调用方或 LangGraph 处理
raise TimeoutError("AI 模型响应超时,请稍后重试")

# L188: 赋值:更新局部变量或 state 字段
result = response.content

# L190: 记录日志,便于线上排查节点入参/出参
logger.info(
# L191: 执行该语句(细节见上文业务描述)
f"[invoke_llm] 执行完成,返回文本长度: {len(result) if result else 0}"
# L192: 执行该语句(细节见上文业务描述)
)
# L193: 记录日志,便于线上排查节点入参/出参
logger.debug(f"[invoke_llm] 返回内容预览: {result[:200] if result else '空'}")

# L195: 返回本节点要合并进 state 的字段(LangGraph 会 merge)
return result

# L197: 捕获异常,避免整图/整请求崩溃
except TimeoutError:
# L198: 向上抛出异常,由调用方或 LangGraph 处理
raise
# L199: 捕获异常,避免整图/整请求崩溃
except Exception as e:
# L200: 记录日志,便于线上排查节点入参/出参
logger.error(
# L201: 赋值:更新局部变量或 state 字段
f"[invoke_llm] 调用 LLM 异常,model={getattr(model, 'model_name', 'unknown')},"
# L202: 执行该语句(细节见上文业务描述)
f"异常: {e}",
# L203: 赋值:更新局部变量或 state 字段
exc_info=True
# L204: 执行该语句(细节见上文业务描述)
)
# L205: 向上抛出异常,由调用方或 LangGraph 处理
raise

invoke_llm_with_json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# ========== invoke_llm_with_json ==========
# 源文件: llm/providers.py 行 208-278

# L208: 异步函数 invoke_llm_with_json:可被 await,适合 IO 型 LLM/DB 调用
async def invoke_llm_with_json(model: ChatOpenAI, messages: list, **kwargs) -> dict:
# L210: 【文档】调用 LLM 并解析 JSON 输出。
# L212: 【文档】功能描述:
# L213: 【文档】使用指定的 ChatOpenAI 模型实例,传入消息列表异步调用 LLM,
# L214: 【文档】要求模型以 JSON 格式回复,并自动解析回复内容为 Python 字典。
# L215: 【文档】适用于需要结构化数据输出的场景,如履历解析、职业匹配结果等。
# L216: 【文档】优先使用 response_format JSON 模式,若不支持则回退到文本解析。
# L218: 【文档】入参说明:
# L219: 【文档】model (ChatOpenAI): 已配置好的 ChatOpenAI 模型实例
# L220: 【文档】messages (list): 消息列表,格式为 [{"role": "system/user/assistant", "content": "..."}]
# L221: 【文档】**kwargs: 额外参数,如 temperature、max_tokens 等覆盖默认配置
# L223: 【文档】出参说明:
# L224: 【文档】dict: 解析后的 JSON 字典数据
# (L209-225 为函数/模块文档字符串,已转为注释便于阅读)
# L226: 导入依赖模块
import json

# L228: 导入依赖模块
from ican.llm.parsers import parse_json_from_text

# L230: 开始 try 块,后续 except 负责兜底
try:
# L231: 记录日志,便于线上排查节点入参/出参
logger.info(
# L232: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
f"[invoke_llm_with_json] 开始执行,入参: model={model.model_name},"
# L233: 执行该语句(细节见上文业务描述)
f"messages 数量: {len(messages)},kwargs: {kwargs}"
# L234: 执行该语句(细节见上文业务描述)
)
# L235: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
logger.debug(f"[invoke_llm_with_json] 消息详情: {messages}")

# L237: 赋值:更新局部变量或 state 字段
processed = _inject_no_think(messages)
# L238: 赋值:更新局部变量或 state 字段
raw_content = None

# L240: 导入依赖模块
import asyncio as _asyncio

# L242: 开始 try 块,后续 except 负责兜底
try:
# L243: 尝试 OpenAI JSON 模式,不支持则走 except 降级
json_model = model.bind(response_format={"type": "json_object"})
# L244: 开始 try 块,后续 except 负责兜底
try:
# L245: 硬超时包装,防止 LLM 挂死
response = await _asyncio.wait_for(json_model.ainvoke(processed, **kwargs), timeout=60)
# L246: 捕获异常,避免整图/整请求崩溃
except _asyncio.TimeoutError:
# L247: 向上抛出异常,由调用方或 LangGraph 处理
raise TimeoutError("AI 模型响应超时,请稍后重试")
# L248: 赋值:更新局部变量或 state 字段
raw_content = response.content
# L249: 捕获异常,避免整图/整请求崩溃
except TimeoutError:
# L250: 向上抛出异常,由调用方或 LangGraph 处理
raise
# L251: 捕获异常,避免整图/整请求崩溃
except Exception as bind_err:
# L252: 记录日志,便于线上排查节点入参/出参
logger.warning(
# L253: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
f"[invoke_llm_with_json] response_format JSON 模式不支持,回退到文本模式: {bind_err}"
# L254: 执行该语句(细节见上文业务描述)
)
# L255: 开始 try 块,后续 except 负责兜底
try:
# L256: 硬超时包装,防止 LLM 挂死
response = await _asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
# L257: 捕获异常,避免整图/整请求崩溃
except _asyncio.TimeoutError:
# L258: 向上抛出异常,由调用方或 LangGraph 处理
raise TimeoutError("AI 模型响应超时,请稍后重试")
# L259: 赋值:更新局部变量或 state 字段
raw_content = response.content

# L261: 记录日志,便于线上排查节点入参/出参
logger.debug(
# L262: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
f"[invoke_llm_with_json] 原始回复长度: {len(raw_content) if raw_content else 0}"
# L263: 执行该语句(细节见上文业务描述)
)

# L265: 开始 try 块,后续 except 负责兜底
try:
# L266: 把 LLM 返回字符串解析为 Python dict
result = json.loads(raw_content)
# L267: 捕获异常,避免整图/整请求崩溃
except (json.JSONDecodeError, TypeError):
# L268: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
logger.info("[invoke_llm_with_json] 直接 JSON 解析失败,尝试 parse_json_from_text 提取")
# L269: 从 LLM 文本中提取 JSON(四层正则/解析策略)
result = parse_json_from_text(raw_content)
# L270: 条件分支
if not result:
# L271: 向上抛出异常,由调用方或 LangGraph 处理
raise ValueError(f"无法从 LLM 回复中提取有效 JSON,原始内容: {raw_content[:300]}")

# L273: 记录日志,便于线上排查节点入参/出参
logger.info(
# L274: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
f"[invoke_llm_with_json] 执行完成,返回 JSON 字段数: {len(result)}"
# L275: 执行该语句(细节见上文业务描述)
)
# L276: 调用 LLM 并解析 JSON;内部有 JSON mode → 文本降级链
logger.debug(f"[invoke_llm_with_json] 返回 JSON 预览: {str(result)[:300]}")

# L278: 返回本节点要合并进 state 的字段(LangGraph 会 merge)
return result

系列导航

主题
1 系统全景
2 五 Agent 协作
3 霍兰德 RIASEC
4–7 状态 · 路由 · 嵌套 · 容错
8–11 LLM 层 · SSE/WS · DB 迁移 · PDF
12–14 JSON Prompt · RIASEC Prompt · Guide Prompt
15–17 Docker · 中间件 · 配置

← 返回 iCan 专题