0. 系列闭环(不公开源码也能跟读)
端到端链路:Vue 前端 → api/routes/chat.py → Guide 多轮 SSE → run_analysis_pipeline(解析→分析→匹配→报告)→ tools/pdf_exporter PDF。
本篇:第 7/17 篇 · 容错环 · 不崩溃
| 阶段 |
用户可见 |
代码入口 |
对应篇 |
| 建会话 |
欢迎语 |
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 |
|
说明 |
| 读本篇前 |
第 05 篇路由、第 08 篇 LLM 调用 |
| 读完本篇 |
列举 Ollama 不可用时的降级链 |
| 下一环 |
第 09 篇:后台任务 _run_analysis_background(第 8 篇) |
全系列闭环索引:SERIES-LOOP.md
一、要解决什么问题
iCan 顶层 workflow 串联 5 个依赖 LLM 的节点(Guide → ResumeParser → ProfileAnalyzer → CareerMatcher → Reporter)。任意一步超时、返回非法 JSON、或 Ollama/云端 API 宕机,若不做隔离,整次分析会 500,用户已填的对话也白费。
项目在三个层次做容错:
- 调用前:
llm/providers.py 的 check_ollama_available 探测 LLM 是否可达;
- 调用中:
invoke_llm / invoke_llm_with_json 的 60s 超时 + llm/parsers.py 的多策略 JSON 提取;
- 调用后:
workflow.py 每个节点的 try/except,以及 run_analysis_pipeline 的分阶段 catch 与 _generate_fallback_report 规则引擎兜底。

二、策略一:健康检查 + 30 秒缓存
run_analysis_pipeline 在跑四个分析 Agent 之前,先调 check_ollama_available()(函数名历史遗留,实际探测的是 settings.LLM_BASE_URL 上的 OpenAI 兼容 /chat/completions,不限于 Ollama)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| _ollama_cache = {"available": True, "last_check": 0}
async def check_ollama_available() -> bool: now = _time.time() if now - _ollama_cache["last_check"] < 30: return _ollama_cache["available"]
_ollama_cache["last_check"] = now base_url = settings.LLM_BASE_URL.rstrip("/") resp = await client.post( f"{base_url}/chat/completions", json={ "model": settings.LLM_MODEL_CHAT, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5, }, )
|
设计要点:
- 30 秒缓存:避免每个 session 连打探测请求,把延迟和配额开销压下去;
- **
max_tokens=5**:最小化探测成本;
- 失败写缓存 False:后续 30 秒内快速走降级,不再反复超时等待。
不可用时,workflow.py 的 run_analysis_pipeline 跳过四个 LLM Agent,改走 _regex_quick_profile + _generate_fallback_report,并在 DB 里标记 ollama_unavailable: True。
三、策略二:asyncio.wait_for 硬超时
llm/providers.py 的 invoke_llm 对所有 Chat 调用包一层 60 秒上限:
1
| response = await asyncio.wait_for(model.ainvoke(processed, **kwargs), timeout=60)
|
超时抛 TimeoutError("AI 模型响应超时,请稍后重试")。get_chat_model() 里还有 request_timeout=90(HTTP 层),60s 是应用层更早切断。
API 层在 api/routes/chat.py 对 run_guide_chat 再包一层 90 秒 wait_for,给用户更友好的「请稍后重发」文案,而不是裸 500。
经验区间(非硬编码规则):普通回复 2–5s,ProfileAnalyzer 10–30s,Reporter 章节生成可能 30–50s;超过 60s 按异常处理。
四、策略三:JSON 四层降级解析
结构化 Agent(ResumeParser、CareerMatcher 等)走 invoke_llm_with_json:先尝试 response_format=json_object,不支持则回退普通文本,再用 llm/parsers.py 的 parse_json_from_text:
1 2 3 4 5 6 7 8 9
| 策略1:```json ... ``` 代码块 ↓ 失败 策略2:普通 ``` ... ```(以 { 或 [ 开头) ↓ 失败 策略3:正则匹配最外层 { ... } ↓ 失败 策略4:json.loads 全文 ↓ 失败 返回 {}(不抛异常)
|
parse_json_from_text 任何 JSONDecodeError 都 catch 后返回 {},保证上游总能拿到 dict。invoke_llm_with_json 若 {} 仍会 raise ValueError——那是「业务必须要有 JSON」的场景,和解析器「尽量提取」的分工不同。
五、策略四:节点级异常隔离
workflow.py 里五个顶层节点各自 try/except,失败时不 raise,而是写安全默认值,让 LangGraph 继续往下走(或至少返回可展示状态):
| 节点 |
异常时返回 |
guide_node |
保留原 conversation_history,needs_more_info=True |
resume_parser_node |
structured_profile={} |
profile_analyzer_node |
personal_profile={} |
career_matcher_node |
career_matches=[] |
reporter_node |
固定 Markdown 失败文案 |
reporter_node 兜底示例:
1 2 3 4 5 6 7
| except Exception as e: logger.error("[reporter_node] 报告输出节点执行异常: %s", e, exc_info=True) return { "final_report": "# iCan 职业规划报告\n\n报告生成失败,请稍后重试。", "current_agent": "reporter", "workflow_messages": [f"报告输出节点异常: {str(e)}"], }
|
对比:无隔离时 Reporter 抛错 → 整图 ainvoke 失败 → CLI/API 500;有隔离时用户至少看到失败说明或部分章节。
route_after_guide 异常时返回 resume_parser_node,属于路由层的「 Fail-open 推进」,与 guide 节点 Fail-closed(继续要信息)形成对比——路由层更怕死循环。
六、策略五:run_analysis_pipeline 分阶段容错
线上报告生成主要走 run_analysis_pipeline(api/routes/chat.py、upload.py、report_gen.py 调用),不经过顶层 LangGraph 的 guide 环。其容错是「每阶段独立 try,失败用空数据继续」:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| try: parser_result = await run_resume_parser(parser_state) structured_profile = parser_result.get("structured_profile", {}) except Exception as parser_err: structured_profile = {}
if not structured_profile: structured_profile = {"basic_info": {"raw_text": combined_text[:500], "source": "fallback"}}
try: analyzer_result = await run_profile_analyzer(analyzer_state) except Exception as analyzer_err: analyzer_result = {}
|
Reporter 阶段失败时,不用空字符串糊弄,而是拼一段含 personal_profile JSON 摘要的 Markdown,并把 reporter_err 写进文末,方便运维对照日志。
LLM 完全不可用时,整条 LLM 链跳过,_generate_fallback_report 输出带 ⚠️ 说明的规则引擎报告:
1
| sections.append("> ⚠️ 注意:AI 模型暂不可用,本报告基于规则引擎快速生成。")
|
外层仍有总 catch:记录日志、ws_manager.send_error 通知前端,再 raise——那是 DB/会话级灾难,不是单 Agent 失败。
七、与循环上限的联动(第 5 篇)
容错也包含 防无限循环(详见第 5 篇):
agents/guide.py should_continue:loop_count >= 8;
workflow.py route_after_guide:user_msg_count >= 3;
recursion_limit:子图 15,完整 workflow 50。
循环超限本质是「强制推进」,避免 error + retry 在图里形成逻辑死循环。
八、容错层次总览
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 请求进入 run_analysis_pipeline / run_workflow ↓ [1] check_ollama_available → 不可用 → _regex_quick_profile + _generate_fallback_report ↓ [2] invoke_llm wait_for 60s → TimeoutError → 节点/API 层捕获 ↓ [3] parse_json_from_text 四层 → 失败 → {} ↓ [4] 各 workflow 节点 try/except → 安全默认值 ↓ [5] pipeline 分阶段 try → 空 dict/list 继续 + reporter 摘要兜底 ↓ [6] 循环/recursion_limit → 强制 handoff / resume_parser ↓ 返回 final_report(完整、部分或规则引擎版)
|
九、踩坑与边界
check_ollama_available 名字误导
探测的是当前 LLM_BASE_URL(可以是 DeepSeek、OpenAI、Ollama),不是只查 Ollama。.env 切云端后,Ollama 挂了但云端正常,仍会按云端结果缓存 True/False。
健康检查默认 _ollama_cache["available"] = True
进程刚启动、尚未探测时,第一次 pipeline 会假设可用;若实际不可用,要等第一次 POST 失败才缓存 False。高可用场景可考虑启动时预热探测。
节点隔离「空 dict 继续」会产出薄报告
profile_analyzer 失败后 personal_profile 大量字段为空,Reporter 仍会跑——用户看到的是「有报告但内容空洞」,比 500 好,但要在前端用 workflow_messages 或进度提示区分。
run_guide_chat 异常有独立兜底
返回固定话术「抱歉,处理出了点问题,能再说一次吗?」,is_info_sufficient=False,不会误触发 run_analysis_pipeline。
Reporter 章节生成走 get_chat_model()
与 get_light_model() 分工不同;勿按旧注释假设 Reporter 已切 mini 模型(见第 8 篇调用表)。
容错路径里 Reporter 仍可能最慢、最易超时;规则引擎降级只覆盖「整个 LLM 不可用」,不覆盖「仅 Reporter 超时」。
十、小结
- 调用前:
llm/providers.py 缓存式健康检查,不可用时 workflow.py 规则引擎出报告。
- 调用中:60s 超时 +
llm/parsers.py 多策略 JSON 提取。
- 调用后:五个 workflow 节点各自隔离;
run_analysis_pipeline 分阶段 catch,Reporter 失败仍有摘要版。
- 目标不是「永不失败」,而是 失败可感知、可降级、不拖垮整图。
- 下一篇(第 8 篇)展开
get_chat_model / get_light_model 与统一 LLM 调用接口。
附录:关键源码(逐行注释)
以下代码摘自 iCan 实现,每行上方均有中文注释,不公开仓库也可跟读。
生成命令:python3 bin/build-ican-annotated-snippets.py
guide_node 异常返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
except Exception as e:
logger.error("[guide_node] 对话引导节点执行异常: %s", e, exc_info=True)
return {
"conversation_history": state.get("conversation_history", []),
"current_agent": "guide",
"needs_more_info": True,
"workflow_messages": [f"对话引导节点异常: {str(e)}"],
}
|
Ollama 不可用 → 规则报告
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
|
from ican.llm.providers import check_ollama_available
ollama_ok = await check_ollama_available()
if not ollama_ok:
logger.warning("[run_analysis_pipeline] Ollama 不可用,使用快速规则引擎生成报告")
structured_profile = _regex_quick_profile(combined_text)
final_report = _generate_fallback_report(structured_profile, combined_text)
logger.info("[run_analysis_pipeline] 快速报告生成完成,长度=%d", len(final_report))
try:
from ican.db.session import get_db_session
from ican.db.repository import SessionRepository
db = next(get_db_session())
try:
repo = SessionRepository(db)
repo.save_session(
session_id=session_id,
user_id=user_id or "system",
status="completed",
current_stage="report",
workflow_data={
"structured_profile": structured_profile,
"final_report": final_report,
"ollama_unavailable": True,
},
)
finally:
db.close()
except Exception as db_err:
logger.error("[run_analysis_pipeline] 保存快速报告失败: %s", db_err)
return {
"structured_profile": structured_profile,
"personal_profile": {},
"career_matches": [],
"final_report": final_report,
}
|
pipeline 分阶段 try/except
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
|
parser_state = {"raw_input": combined_text, "input_type": "text"}
try:
parser_result = await run_resume_parser(parser_state)
structured_profile = parser_result.get("structured_profile", {})
except Exception as parser_err:
logger.error("[run_analysis_pipeline] 简历解析失败,使用空数据继续: %s", parser_err)
structured_profile = {}
if not structured_profile or len(structured_profile) == 0:
logger.warning("[run_analysis_pipeline] 结构化画像为空,尝试从原始文本构建基础数据")
structured_profile = {"basic_info": {"raw_text": combined_text[:500], "source": "fallback"}}
try:
analyzer_state = {"structured_profile": structured_profile}
analyzer_result = await run_profile_analyzer(analyzer_state)
except Exception as analyzer_err:
logger.error("[run_analysis_pipeline] 个人分析失败,使用空数据继续: %s", analyzer_err)
analyzer_result = {}
personal_profile = {
"structured_profile": structured_profile,
"ability_model": analyzer_result.get("ability_model", {}),
"work_style": analyzer_result.get("work_style", {}),
"personality_traits": analyzer_result.get("personality_traits", {}),
"career_values": analyzer_result.get("career_values", {}),
"riasec_scores": analyzer_result.get("riasec_scores", {}),
"strengths": analyzer_result.get("strengths", []),
"weaknesses": analyzer_result.get("weaknesses", []),
"overall_summary": analyzer_result.get("structured_profile", {}).get("overall_summary", ""),
}
try:
matcher_state = {"personal_profile": personal_profile}
matcher_result = await run_career_matcher(matcher_state)
career_matches = matcher_result.get("recommended_paths", [])
except Exception as matcher_err:
logger.error("[run_analysis_pipeline] 职业匹配失败,使用空数据继续: %s", matcher_err)
career_matches = []
reporter_state = {
"personal_profile": personal_profile,
"career_matches": career_matches,
"action_plan": {},
}
try:
reporter_result = await run_reporter(reporter_state)
final_report = reporter_result.get("final_report", "")
except Exception as reporter_err:
logger.error("[run_analysis_pipeline] 报告生成失败: %s", reporter_err)
final_report = f"# 职业规划报告\n\n基于您的简历分析,报告生成过程中遇到问题。\n\n## 个人画像摘要\n\n{json.dumps(personal_profile, ensure_ascii=False, default=str)[:2000]}\n\n*完整报告生成失败: {reporter_err}*"
|
系列导航
← 返回 iCan 专题