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-4o,LLM_MODEL_LIGHT=gpt-4o-mini,LLM_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
|

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
|
两点:
- 硬超时 60s(
wait_for),与 ChatOpenAI(request_timeout=90) 是两层超时,以 60s 先到为准。
- **
_inject_no_think**:当 LLM_BASE_URL 含 11434 且 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)
|
顺序:JSON mode → 纯文本 + 正则/括号提取。第 12 篇专讲 parse_json_from_text 四层降级。
6. LLM 不可用时的降级(workflow 层)
workflow.py 的 run_analysis_pipeline 在跑四段分析前会调 check_ollama_available():
- 每 30s 缓存一次探测结果;
- 对
LLM_BASE_URL/chat/completions 发 5 token 探活;
- 若不可用:不走 LLM,用
_regex_quick_profile + _generate_fallback_report 出规则版报告,并写入 Session.workflow_data(ollama_unavailable: true)。
这是 业务层降级,不是 providers.py 内部逻辑。文档里应分开写,避免读者以为 invoke_llm 会自动 fallback。
7. 踩坑
① 注释与调用不一致
get_light_model docstring 写「报告格式化」,但 reporter.py 的 generate_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.py、api/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
|
def get_chat_model() -> ChatOpenAI:
try:
logger.info(
f"[get_chat_model] 开始执行,入参: 无,"
f"使用模型: {settings.LLM_MODEL_CHAT},"
f"BASE_URL: {settings.LLM_BASE_URL}"
)
model = 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,
)
logger.info(
f"[get_chat_model] 执行完成,返回: ChatOpenAI 实例,"
f"模型: {settings.LLM_MODEL_CHAT}"
)
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
|
async def invoke_llm(model: ChatOpenAI, messages: list, **kwargs) -> str:
try:
logger.info(
f"[invoke_llm] 开始执行,入参: model={model.model_name},"
f"messages 数量: {len(messages)},kwargs: {kwargs}"
)
logger.debug(f"[invoke_llm] 消息详情: {messages}")
processed = _inject_no_think(messages)
import asyncio
try:
response = await asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
except asyncio.TimeoutError:
logger.error("[invoke_llm] LLM 调用超时 (60s)")
raise TimeoutError("AI 模型响应超时,请稍后重试")
result = response.content
logger.info(
f"[invoke_llm] 执行完成,返回文本长度: {len(result) if result else 0}"
)
logger.debug(f"[invoke_llm] 返回内容预览: {result[:200] if result else '空'}")
return result
except TimeoutError:
raise
except Exception as e:
logger.error(
f"[invoke_llm] 调用 LLM 异常,model={getattr(model, 'model_name', 'unknown')},"
f"异常: {e}",
exc_info=True
)
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
|
async def invoke_llm_with_json(model: ChatOpenAI, messages: list, **kwargs) -> dict:
import json
from ican.llm.parsers import parse_json_from_text
try:
logger.info(
f"[invoke_llm_with_json] 开始执行,入参: model={model.model_name},"
f"messages 数量: {len(messages)},kwargs: {kwargs}"
)
logger.debug(f"[invoke_llm_with_json] 消息详情: {messages}")
processed = _inject_no_think(messages)
raw_content = None
import asyncio as _asyncio
try:
json_model = model.bind(response_format={"type": "json_object"})
try:
response = await _asyncio.wait_for(json_model.ainvoke(processed, **kwargs), timeout=60)
except _asyncio.TimeoutError:
raise TimeoutError("AI 模型响应超时,请稍后重试")
raw_content = response.content
except TimeoutError:
raise
except Exception as bind_err:
logger.warning(
f"[invoke_llm_with_json] response_format JSON 模式不支持,回退到文本模式: {bind_err}"
)
try:
response = await _asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
except _asyncio.TimeoutError:
raise TimeoutError("AI 模型响应超时,请稍后重试")
raw_content = response.content
logger.debug(
f"[invoke_llm_with_json] 原始回复长度: {len(raw_content) if raw_content else 0}"
)
try:
result = json.loads(raw_content)
except (json.JSONDecodeError, TypeError):
logger.info("[invoke_llm_with_json] 直接 JSON 解析失败,尝试 parse_json_from_text 提取")
result = parse_json_from_text(raw_content)
if not result:
raise ValueError(f"无法从 LLM 回复中提取有效 JSON,原始内容: {raw_content[:300]}")
logger.info(
f"[invoke_llm_with_json] 执行完成,返回 JSON 字段数: {len(result)}"
)
logger.debug(f"[invoke_llm_with_json] 返回 JSON 预览: {str(result)[:300]}")
return result
|
系列导航
← 返回 iCan 专题