0. 系列闭环(不公开源码也能跟读) 端到端链路 :Vue 前端 → api/routes/chat.py → Guide 多轮 SSE → run_analysis_pipeline(解析→分析→匹配→报告)→ tools/pdf_exporter PDF。本篇 :第 13/17 篇 · Prompt 环 · RIASEC
阶段
用户可见
代码入口
对应篇
建会话
欢迎语
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
说明
读本篇前
第 03 篇 analyze_riasec
读完本篇
对照 Prompt 与解析器输出字段
下一环
第 14 篇:Guide 五阶段 Prompt(第 14 篇)
全系列闭环索引:SERIES-LOOP.md
1. 痛点:量表逻辑 vs 对话推断 传统 SDS 问卷有 120 道固定题目,用户逐题打分,系统统计六维度得分。 iCan 走的是另一条路:用户在 Guide 对话里自然描述履历与偏好 ,ProfileAnalyzer 的 analyze_riasec 节点再从结构化画像推断 R/I/A/S/E/C。
难点在于:LLM 容易「凭感觉给分」,缺少依据时仍输出精确数字,下游 CareerMatcher 会把错误代码当真。
本篇说明 Prompt 如何把心理学维度定义写清楚,并约束「评分 + 理由 + 信息不足时声明范围」。
2. 在流水线中的位置 实现入口:agents/profile_analyzer.py 的 create_profile_analyzer_graph()。子图在能力、风格、性格、价值观分析之后进入 analyze_riasec,结果经 workflow.py 的 profile_analyzer_node 写入外层 personal_profile.riasec_scores:
1 2 3 4 5 6 7 8 9 10 11 12 flowchart LR P[structured_profile] --> L[load_profile] L --> AB[analyze_abilities] AB --> WS[infer_work_style] WS --> PT[infer_personality] PT --> AV[analyze_values] AV --> AR[analyze_riasec] AR --> SW[identify_strengths_weaknesses] SW --> SY[synthesize_profile] AR --> J[invoke_llm_with_json] J --> R[riasec_scores] R --> M[career_matcher_node]
各节点均使用 get_chat_model()(llm/providers.py),System Prompt 共用 llm/prompts.py 的 PROFILE_ANALYZER_SYSTEM_PROMPT。
3. Prompt 结构:系统模板 + 动态 user 内容 RIASEC 规则写在 llm/prompts.py 的 PROFILE_ANALYZER_SYSTEM_PROMPT 第六节,与能力模型、大五人格等共用一份 System Prompt,避免各节点 Prompt 分散难维护。
核心片段(节选):
1 2 3 4 5 6 7 8 9 10 11 12 基于约翰·霍兰德的职业兴趣理论,分析用户在六个维度的倾向: - R(Realistic)现实型:喜欢操作、实践、动手 - I(Investigative)研究型:喜欢分析、探索、研究 每个维度给出 0 -10 分的评分,并标注最高的 2 -3 个维度作为用户的"霍兰德代码" 。 - 评分要有依据,不要随意打分,每个评分都要在 analysis 中说明理由 - 霍兰德分析要结合用户的工作经历和技能来推断,而不是凭空猜测 - 如果信息不足以做出准确判断,在分析中说明并给出可能的范围
analyze_riasec 节点把 structured_profile 截断到约 2000 字符,拼进 user 消息,再调用 invoke_llm_with_json(llm/providers.py):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 async def analyze_riasec (state: ProfileAnalysisState ) -> dict : structured_profile = state.get("structured_profile" , {}) profile_summary = json.dumps(structured_profile, ensure_ascii=False )[:2000 ] messages = [ {"role" : "system" , "content" : PROFILE_ANALYZER_SYSTEM_PROMPT}, {"role" : "user" , "content" : ( f"请基于以下结构化履历信息,分析用户的霍兰德 RIASEC 职业兴趣。\n" f"对六个维度进行评分(0-10分)...\n\n履历信息:\n{profile_summary} " )}, ] riasec_data = await invoke_llm_with_json(get_chat_model(), messages) riasec_scores = {k: float (riasec_data.get(k, 0 )) for k in "RIASEC" } return {"riasec_scores" : riasec_scores}
若 invoke_llm_with_json 的 response_format 被 Ollama 忽略,可回退到 llm/parsers.py 的 parse_riasec_scores() 从纯文本提取六维分数。
注意 :RIASEC 与五维分析在同一份 JSON Schema 里定义,但运行时由独立节点调用,便于单独重试或替换模型。
4. 期望 JSON 形状与 Holland Code 模型应返回嵌套在完整画像 JSON 中的 riasec 块:
1 2 3 4 5 6 7 { "riasec" : { "R" : 6 , "I" : 9 , "A" : 3 , "S" : 5 , "E" : 7 , "C" : 4 , "holland_code" : "IEA" , "analysis" : "I 维度:用户多次提到架构设计与技术选型……" } }
Holland Code 取得分最高的 2–3 个字母。实现里 LLM 返回的 holland_code 在 JSON 中,但 analyze_riasec 只持久化 R–C 六维 float 到 riasec_scores;若 JSON 解析失败,节点退回全零分字典,避免整个子图崩溃(见第 07 篇容错)。tools/pdf_exporter.py 读 riasec_scores 的六键画柱状图,不依赖 holland_code 字段。
5. 与 CareerMatcher 的衔接 agents/career_matcher.py 的 generate_candidate_paths 读取 personal_profile(含 riasec_scores),配合 llm/prompts.py 的 CAREER_MATCHER_SYSTEM_PROMPT 生成三级路径。Prompt 里要求结合霍兰德代码解释推荐,而不是维护静态映射表:
1 2 3 基于用户的霍兰德代码 IRS,在推荐中说明: - 哪些方向与 I/R 一致(如技术架构、数据工程) - 哪些方向是 stretch(如纯销售型 E 向岗位)
workflow.py 的 career_matcher_node 把 recommended_paths 写入外层 career_matches,供 agents/reporter.py 写进报告。
6. 与传统问卷的边界(写进产品文案)
维度
SDS 问卷
iCan LLM 评估
标准化
高
中(依赖对话质量)
效度
长期验证
无正式量表效度
体验
120 题
多轮对话
适用
正式测评
探索与报告辅助
对外表述应明确:对话推断不能替代标准化职业测评 ,适合作为职业规划讨论的起点。
7. 踩坑记录
画像太薄仍给高分 :agents/resume_parser.py 若只抽到岗位名,RIASEC 会胡编。解决:Guide 阶段尽量收集行业/技能/偏好(第 14 篇);Parser 输出的 confidence_scores(非 parsing_confidence)可供感知解析质量。
Holland Code 字母顺序 :有的模型输出 EIA 而非按分数排序;当前 MVP 依赖 Prompt 约束,未在后处理重排。
与 PDF 图表字段不一致 :tools/pdf_exporter.py 柱状图读 riasec_scores 扁平 dict,key 必须为 R…C 单字母大写,与 analyze_riasec 写入格式一致。
json_object 不支持 :Ollama 部分模型忽略 response_format,必须走第 12 篇四层 llm/parsers.py 的 parse_json_from_text / parse_riasec_scores。
System Prompt 第六节与节点 user Prompt 重复 :PROFILE_ANALYZER_SYSTEM_PROMPT 已含 RIASEC 定义,analyze_riasec 的 user 消息再次列维度——有意加强约束,修改时需两处同步。
8. 小结 霍兰德 Prompt 的关键不是背六维度定义,而是:绑定 structured_profile、强制理由、承认信息不足、输出 holland_code 给下游 Matcher 。
下一篇:Guide 五阶段对话 Prompt — GUIDE_SYSTEM_PROMPT 与 check_sufficiency 双轨判断。
附录:关键源码(逐行注释) 以下代码摘自 iCan 实现,每行上方均有中文注释 ,不公开仓库也可跟读。 生成命令:python3 bin/build-ican-annotated-snippets.py
Prompt 常量(节选) 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 116 117 118 119 120 121 122 123 GUIDE_SYSTEM_PROMPT = """你是一位拥有20年经验的高级职业规划师,名叫"小C"。你同时具备心理咨询师和职业顾问的双重素养,擅长通过自然对话深入了解一个人的职业发展需求。 # L20: 你的核心能力 # L21: 执行该语句(细节见上文业务描述) 1. **职业规划专业能力**:精通各行业职业发展路径、市场趋势、岗位要求 # L22: 执行该语句(细节见上文业务描述) 2. **心理咨询能力**:善于倾听、共情,能识别用户的情绪状态和深层需求 # L23: 执行该语句(细节见上文业务描述) 3. **信息挖掘能力**:通过渐进式提问,系统性地收集用户的关键信息 # L24: 执行该语句(细节见上文业务描述) 4. **分析判断能力**:基于收集的信息,快速形成对用户的初步画像 # L26: 对话策略 # L27: 执行该语句(细节见上文业务描述) - **渐进式挖掘**:从开放式问题开始,逐步聚焦到具体细节,避免一上来就问太多结构化问题 # L28: 执行该语句(细节见上文业务描述) - **情绪识别**:关注用户的情绪变化,适时给予鼓励和认可,建立信任感 # L29: 执行该语句(细节见上文业务描述) - **灵活应对**:根据用户的回答灵活调整对话方向,不要机械地按照固定流程走 # L30: 执行该语句(细节见上文业务描述) - **信息补全**:在自然对话中巧妙地引导用户提供缺失的关键信息 # L31: 执行该语句(细节见上文业务描述) - **总结确认**:在关键节点主动总结已收集的信息,确保理解准确 # L33: 对话阶段 # L34: 执行该语句(细节见上文业务描述) 你会根据对话进展自动判断当前所处的阶段: # L35: 执行该语句(细节见上文业务描述) 1. **greeting(开场问候)**:热情欢迎用户,简要介绍服务,了解用户的基本来意 # L36: 执行该语句(细节见上文业务描述) 2. **assess_need(需求评估)**:判断用户的核心需求是职业规划、转型咨询还是简历优化等 # L37: 执行该语句(细节见上文业务描述) 3. **collect_basic_info(基础信息收集)**:了解用户的教育背景、工作年限、当前岗位等基础信息 # L38: 执行该语句(细节见上文业务描述) 4. **dig_deeper(深度挖掘)**:深入了解用户的核心技能、职业成就、工作偏好、价值观等 # L39: 执行该语句(细节见上文业务描述) 5. **confirm(确认总结)**:总结所有收集到的信息,与用户确认后准备生成分析报告 # L41: 重要规则 # L42: 执行该语句(细节见上文业务描述) - 始终使用中文回复 # L43: 执行该语句(细节见上文业务描述) - 语气亲切专业,像一位经验丰富的朋友 # L44: 执行该语句(细节见上文业务描述) - 每次回复控制在200字以内,保持对话节奏 # L45: 执行该语句(细节见上文业务描述) - 不要一次性问太多问题,每次最多提1-2个问题 # L46: 执行该语句(细节见上文业务描述) - 如果用户回答模糊,可以用具体场景来引导 # L47: 执行该语句(细节见上文业务描述) - 如果用户表达了负面情绪,先给予情感支持再继续引导 # L48: 执行该语句(细节见上文业务描述) - 当收集到足够信息后,主动提议进入分析阶段 # L51: 【文档】GUIDE_STAGE_TEMPLATES = { # L52: 【文档】"greeting": ( # L53: 【文档】"用户刚刚开始对话。请热情地欢迎用户,简要介绍你能提供的职业规划服务," # L54: 【文档】"并用一个轻松的开放性问题了解用户的来意。\n\n" # L55: 【文档】"示例开场白:你好!我是小C,你的专属职业规划顾问。我拥有20年的职业规划经验," # L56: 【文档】"帮助过上千位职场人找到自己的方向。今天想聊聊什么?是遇到了职业瓶颈," # L57: 【文档】"还是在考虑新的发展方向?" # L58: 【文档】), # L59: 【文档】"assess_need": ( # L60: 【文档】"用户已经开始了对话。请通过1-2个精准的问题,判断用户的核心需求类别:\n" # L61: 【文档】"- 职业规划:需要明确职业方向或制定发展计划\n" # L62: 【文档】"- 职业转型:想转换行业或岗位\n" # L63: 【文档】"- 简历优化:需要优化求职材料\n" # L64: 【文档】"- 技能提升:想了解需要补充哪些能力\n" # L65: 【文档】"- 职场困惑:遇到具体的工作困境\n\n" # L66: 【文档】"根据用户的回答,自然地引导对话深入。不要直接问'你需要什么服务'," # L67: 【文档】"而是通过对话内容来推断。" # L68: 【文档】), # L69: 【文档】"collect_basic_info": ( # L70: 【文档】"已经了解了用户的基本需求。现在请在自然对话中收集以下基础信息(不要一次性全问):\n" # L71: 【文档】"1. 教育背景(学历、专业、毕业院校)\n" # L72: 【文档】"2. 工作年限和行业经验\n" # L73: 【文档】"3. 当前/最近的岗位和职责\n" # L74: 【文档】"4. 核心技能和技术栈\n" # L75: 【文档】"5. 重要的职业成就或项目经历\n\n" # L76: 【文档】"注意:要像聊天一样自然地获取这些信息,而不是像面试一样逐条询问。" # L77: 【文档】"可以先从用户最愿意谈论的话题切入。" # L78: 【文档】), # L79: 【文档】"dig_deeper": ( # L80: 【文档】"基础信息已经收集得差不多了。现在请深入挖掘以下维度:\n" # L81: 【文档】"1. **工作偏好**:更喜欢独立工作还是团队协作?喜欢稳定还是挑战?\n" # L82: 【文档】"2. **成就感来源**:什么样的事情让用户最有成就感?\n" # L83: 【文档】"3. **职业价值观**:用户最看重什么?(薪资、成长、平衡、影响力等)\n" # L84: 【文档】"4. **性格特点**:偏外向还是内向?擅长分析还是直觉判断?\n" # L85: 【文档】"5. **职业困惑的根源**:真正困扰用户的问题是什么?\n" # L86: 【文档】"6. **期望和目标**:对未来的职业发展有什么期望?\n\n" # L87: 【文档】"可以结合之前收集的信息,提出有针对性的深度问题。" # L88: 【文档】"例如:'你提到之前带团队完成了XX项目,那个时候你觉得最有成就感的部分是什么?'" # L89: 【文档】), # L90: 【文档】"confirm": ( # L91: 【文档】"对话已经收集了足够的信息。请做以下事情:\n" # L92: 【文档】"1. 用简洁的方式总结你了解到的用户信息(教育、经历、技能、偏好、目标等)\n" # L93: 【文档】"2. 询问用户是否有需要补充或纠正的地方\n" # L94: 【文档】"3. 如果用户确认信息无误,告知用户即将进入分析阶段,会生成以下内容:\n" # L95: 【文档】" - 个人职业画像分析\n" # L96: 【文档】" - 职业方向推荐\n" # L97: 【文档】" - 行动建议\n\n" # L98: 【文档】"示例总结格式:\n" # L99: 【文档】'让我总结一下我们聊到的内容:你是一位有X年XX行业经验的专业人士,' # L100: 【文档】'目前从事XX岗位,擅长XX和XX。你希望在XX方向有更好的发展,'
analyze_riasec 调用 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 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 async def analyze_riasec (state: ProfileAnalysisState ) -> dict : try : logger.info("[analyze_riasec] 开始执行,入参: state=%s" , {k: str (v)[:100 ] for k, v in state.items()}) structured_profile = state.get("structured_profile" , {}) profile_summary = json.dumps(structured_profile, ensure_ascii=False )[:2000 ] riasec_prompt = ( f"请基于以下结构化履历信息,分析用户的霍兰德 RIASEC 职业兴趣。\n" f"对六个维度进行评分(0-10分):R(现实型)、I(研究型)、A(艺术型)、" f"S(社会型)、E(企业型)、C(常规型)。\n\n" f"履历信息:\n{profile_summary} \n\n" f"请按以下 JSON 格式输出:\n" f'{{"R": 0到10分, "I": 0到10分, "A": 0到10分, ' f'"S": 0到10分, "E": 0到10分, "C": 0到10分, ' f'"holland_code": "如 IAS", "analysis": "RIASEC 分析文本"}}' ) messages = [ {"role" : "system" , "content" : PROFILE_ANALYZER_SYSTEM_PROMPT}, {"role" : "user" , "content" : riasec_prompt}, ] logger.info("[analyze_riasec] 调用 LLM 分析 RIASEC" ) model = get_chat_model() riasec_data = await invoke_llm_with_json(model, messages) riasec_scores = { "R" : float (riasec_data.get("R" , 0 )), "I" : float (riasec_data.get("I" , 0 )), "A" : float (riasec_data.get("A" , 0 )), "S" : float (riasec_data.get("S" , 0 )), "E" : float (riasec_data.get("E" , 0 )), "C" : float (riasec_data.get("C" , 0 )), } logger.info( "[analyze_riasec] RIASEC 分析完成,holland_code=%s, 得分=%s" , riasec_data.get("holland_code" , "未知" ), riasec_scores, ) result = { "riasec_scores" : riasec_scores, } logger.info("[analyze_riasec] 执行完成,出参: riasec_scores=%s" , riasec_scores) return result except Exception as e: logger.error("[analyze_riasec] 分析 RIASEC 异常: %s" , e, exc_info=True ) return { "riasec_scores" : {"R" : 0.0 , "I" : 0.0 , "A" : 0.0 , "S" : 0.0 , "E" : 0.0 , "C" : 0.0 }, }
parse_riasec_scores 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 def parse_riasec_scores (text: str ) -> dict : try : logger.info(f"[parse_riasec_scores] 开始执行,入参: 文本长度={len (text)} " ) logger.debug(f"[parse_riasec_scores] 文本预览: {text[:300 ]} " ) if not text or not text.strip(): logger.warning("[parse_riasec_scores] 入参文本为空,返回全零得分" ) return {"R" : 0.0 , "I" : 0.0 , "A" : 0.0 , "S" : 0.0 , "E" : 0.0 , "C" : 0.0 } default_scores = {"R" : 0.0 , "I" : 0.0 , "A" : 0.0 , "S" : 0.0 , "E" : 0.0 , "C" : 0.0 } json_data = parse_json_from_text(text) if json_data: name_mapping = { "realistic" : "R" , "investigative" : "I" , "artistic" : "A" , "social" : "S" , "enterprising" : "E" , "conventional" : "C" , "现实型" : "R" , "研究型" : "I" , "艺术型" : "A" , "社会型" : "S" , "企业型" : "E" , "常规型" : "C" , } normalized = {} for k, v in json_data.items(): key_lower = k.lower().strip() if key_lower in name_mapping: normalized[name_mapping[key_lower]] = v elif key_lower.upper() in ["R" , "I" , "A" , "S" , "E" , "C" ]: normalized[key_lower.upper()] = v else : normalized[k] = v scores = {} for key in ["R" , "I" , "A" , "S" , "E" , "C" ]: if key in normalized: try : val = float (normalized[key]) scores[key] = min (10.0 , max (0.0 , val)) except (ValueError, TypeError): scores[key] = 0.0 else : scores[key] = 0.0 if any (v > 0 for v in scores.values()): logger.info(f"[parse_riasec_scores] 执行完成(策略1: JSON提取),返回: {scores} " ) return scores
系列导航
← 返回 iCan 专题