0. Series Loop (Follow Along Without Public Source Code)

End-to-end pipeline: Vue frontend → api/routes/chat.py → Guide multi-turn SSE → run_analysis_pipeline (parse → analyze → match → report) → tools/pdf_exporter PDF.
This article: 2/17 · Pipeline · Five Agent Nodes

Stage User Visible Code Entry Corresponding Article
Create session Welcome message POST /api/sessions 09
Multi-turn chat SSE streaming chat/stream → run_guide_single_turn 06, 14
Info sufficient Start analysis _run_analysis_background 05, 07
Resume parsing Progress 30% run_resume_parser 12
Profile/RIASEC Progress 50% run_profile_analyzer 03, 13
Career matching Progress 70% run_career_matcher 02
Report Progress 90% run_reporter 11
Download PDF File GET …/report/pdf 11, 15
Description
Before reading this Architecture diagram from Article 01
After reading this For each node in workflow.py, state its input/output fields
Next in loop Article 04: Meaning of iCanWorkflowState fields (Article 03)

Full series loop index: SERIES-LOOP.md

1. Why Multiple Agents

A common misconception is that giving an LLM a super prompt can accomplish everything. But in practice you will find:

Problems with the single-prompt approach:

  • Wasted context window: Stuffing all task prompts into one call dilutes useful information.
  • Degraded output quality: The LLM simultaneously parses, analyzes, matches, and writes—none of which it does well.
  • No reusability: Switching to a different scenario (e.g., only resume parsing) requires rewriting the entire prompt.
  • Difficult debugging: When output is faulty, it’s unclear which step went wrong.

Advantages of the multi-agent approach:

  • Each agent focuses on one task, so prompts are more precise.
  • State is passed between agents; output of the upstream is input to the downstream.
  • Individual agents can be tested and optimized independently.
  • Failure of one agent does not affect the design of others.

Five-agent collaboration pipeline

2. Responsibilities of the 5 Agents

1
2
3
4
5
6
flowchart LR
G[guide_node] --> P[resume_parser_node]
P --> A[profile_analyzer_node]
A --> M[career_matcher_node]
M --> R[reporter_node]
G -.info insufficient.-> G

Guide — Dialogue Conductor (agents/guide.py)

Responsibility: Collect user career information through multi-turn dialogue.

It has its own internal StateGraph (5 nodes):

1
2
3
4
5
welcome → assess_need → collect_basic_info → dig_deeper → check_sufficiency

should_continue()
↙ ↘
dig_deeper END
  • welcome: Generate a welcome message and understand the user’s intention.
  • assess_need: Determine the core need (career planning / transition / skill improvement).
  • collect_basic_info: Gather education, years of work experience, current position, etc.
  • dig_deeper: Probe work preferences, values, personality traits.
  • check_sufficiency: The LLM judges whether the information is sufficient; if not, return to dig_deeper.

Key design: check_sufficiency uses conditional routing via should_continue(). The inner loop executes at most 8 times to prevent infinite loops.

ResumeParser — Resume Parser (agents/resume_parser.py)

Responsibility: Convert the user’s natural language descriptions into structured JSON data. In the top-level workflow.py, the resume_parser_node concatenates all user utterances from conversation_history and raw_input into combined_text, then calls run_resume_parser().

Input is a block of text containing all dialogue content; output is a standardized structured profile:

1
2
3
4
5
6
{
"basic_info": {"name": "...", "age": "...", "education": "..."},
"work_experience": [{"company": "...", "position": "...", "duration": "..."}],
"skill_set": {"technical_skills": [...], "soft_skills": [...], "tools": [...]},
"career_progression": {"total_years": 5, "industries": [...], "career_path": "..."}
}

Technical challenge: The JSON format output by the LLM is unstable. agents/resume_parser.py works with llm/parsers.py using a four-level fallback parsing + llm/providers.py‘s invoke_llm_with_json (response_format={"type": "json_object"}) to force JSON mode; the model is taken from get_light_model().

ProfileAnalyzer — Personal Analysis (agents/profile_analyzer.py)

Responsibility: Conduct a deep five‑dimension analysis based on the structured profile.

Five dimensions:

  1. Ability model: Hard skills / soft skills / learning ability / innovation / leadership (score 0‑10)
  2. Work style: Decision‑making approach / collaboration preference / pace preference / communication style
  3. Personality traits: Big Five personality scores on five dimensions
  4. Career values: Ranking of 8 dimensions (material rewards / growth / balance / influence / autonomy / stability / innovation / interpersonal)
  5. Holland RIASEC: Six‑dimension scores (R/I/A/S/E/C) + Holland Code

Technical highlight: The sub‑graph create_profile_analyzer_graph() executes sequentially after load_profile: ability → style → personality → values → analyze_riasec → strengths/weaknesses → synthesize_profile (the comment says “parallel” as design intent, but currently the edges are linear). The RIASEC scoring is covered in Articles 03 and 13.

CareerMatcher — Career Matching (agents/career_matcher.py)

Responsibility: Recommend a three‑tier career path based on the personal profile.

Three‑tier recommendation strategy:

Tier Strategy Match Score Description
First Vertical deepening 80‑95% Go deeper in the current industry
Second Horizontal expansion 60‑80% Move to a related field
Third Transformation exploration 40‑60% New directions based on personal traits

Each tier includes: target position, skill gap, market outlook (demand / salary / trends), and timeline.

Reporter — Report Output (agents/reporter.py)

Responsibility: Integrate all analysis results into a structured Markdown report.

Report structure:

  1. Personal profile overview
  2. Five‑dimension deep analysis
  3. Career direction recommendations (three tiers)
  4. Action suggestions (short / medium / long term)
  5. Market insights

3. Implementation of the StateGraph Connection

Implementation file: workflow.py (the sole top‑level orchestration entry; see SOURCE-ACCURACY.md).

Top‑level Workflow create_workflow()

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
# workflow.py — excerpt
from langgraph.graph import StateGraph, END
from ican.core.state import iCanWorkflowState
from ican.agents.guide import run_guide_agent
# ... resume_parser / profile_analyzer / career_matcher / reporter ...

graph = StateGraph(iCanWorkflowState)
graph.add_node("guide_node", guide_node)
graph.add_node("resume_parser_node", resume_parser_node)
graph.add_node("profile_analyzer_node", profile_analyzer_node)
graph.add_node("career_matcher_node", career_matcher_node)
graph.add_node("reporter_node", reporter_node)

graph.set_entry_point("guide_node")
graph.add_conditional_edges(
"guide_node",
route_after_guide,
{
"guide_node": "guide_node",
"resume_parser_node": "resume_parser_node",
},
)
graph.add_edge("resume_parser_node", "profile_analyzer_node")
graph.add_edge("profile_analyzer_node", "career_matcher_node")
graph.add_edge("career_matcher_node", "reporter_node")
graph.add_edge("reporter_node", END)
compiled_graph = graph.compile()

Outer guide_node: Bridging Inner and Outer State

guide_node maps iCanWorkflowState (core/state.py) to the inner GuideState, calls run_guide_agent(), and converts is_info_sufficient into the outer needs_more_info:

1
2
3
4
5
6
7
8
9
10
# workflow.py — core logic of guide_node
guide_state = create_initial_guide_state()
guide_state["conversation_history"] = conversation_history
guide_result = await run_guide_agent(guide_state)

return {
"conversation_history": updated_history,
"current_agent": "guide",
"needs_more_info": not guide_result.get("is_info_sufficient", False),
}

Outer Routing route_after_guide

Working together with the inner should_continue (loop_count >= 8) in agents/guide.py, there is an additional safety net at the outer level: when user_msg_count >= 3, the flow is forced into parsing to avoid infinite loops when there is no user interaction:

1
2
3
4
5
6
7
8
9
# workflow.py — route_after_guide
def route_after_guide(state: iCanWorkflowState) -> str:
if not state.get("needs_more_info", True):
return "resume_parser_node"
user_msg_count = len([m for m in state.get("conversation_history", [])
if m.get("role") == "user"])
if user_msg_count >= 3:
return "resume_parser_node"
return "guide_node"

Production: run_analysis_pipeline

After information is sufficient, the front‑end SSE dialogue usually does not go through the full ainvoke(create_workflow()), but instead calls run_analysis_pipeline() in workflow.py: it skips Guide, directly chains the four agents, and falls back to a rule‑engine report when Ollama is unavailable. The entry points are in api/routes/chat.py and api/routes/report_gen.py.

Data Transfer Between Nodes

Pattern for each node function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# workflow.py — profile_analyzer_node pattern
async def profile_analyzer_node(state: iCanWorkflowState) -> dict:
structured_profile = state.get("structured_profile", {})
analyzer_state: ProfileAnalysisState = {"structured_profile": structured_profile}
analyzer_result = await run_profile_analyzer(analyzer_state)

complete_profile = {
"structured_profile": structured_profile,
"ability_model": analyzer_result.get("ability_model", {}),
"riasec_scores": analyzer_result.get("riasec_scores", {}),
"strengths": analyzer_result.get("strengths", []),
# work_style / personality_traits / career_values ...
}
return {"personal_profile": complete_profile, "current_agent": "profile_analyzer"}

Key point: The node only returns the fields that need updating; LangGraph automatically merges them into the global state.

4. Comparison with CrewAI / AutoGen

Dimension LangGraph CrewAI AutoGen
Orchestration model Directed graph (DAG) Flow / hierarchy Dialogue rounds
State management TypedDict + Reducer Shared Memory Message history
Conditional routing ✅ Native support ⚠️ Requires customization ❌ Not supported
Loop control ✅ Conditional edges + recursion_limit ⚠️ Limited support ✅ Dialogue loops
Visualization ✅ Export graph structure
Learning curve Medium Low Low
Use case Complex workflows Simple task orchestration Multi‑model conversations

Selection advice:

  • If your agents require conditional routing and loops → LangGraph
  • If it’s simple task assignment → CrewAI
  • If you need multi‑model conversations / debates → AutoGen

5. Pitfalls and Lessons Learned

Pitfall 1: Confusing Reducer field levels

The outer iCanWorkflowState (core/state.py) uses workflow_messages: Annotated[list[str], operator.add] to accumulate workflow logs; the inner GuideState uses messages: Annotated[list[str], operator.add] to accumulate AI replies. If messages is mistakenly used at the top level, LangGraph will not merge as expected.

1
2
3
4
5
# core/state.py — outer
workflow_messages: Annotated[list[str], operator.add]

# core/state.py �� inner GuideState
messages: Annotated[list[str], operator.add]

Pitfall 2: Uncontrolled Loops

The Guide Agent could loop infinitely. The solution is a two‑layer limit:

  • Inner: should_continue() limits to at most 8 iterations.
  • Outer: route_after_guide() limits to at most 3 rounds of user messages.

Pitfall 3: Node Exceptions and Empty Profiles

Each node in workflow.py has an independent try-except; on failure it returns an empty dict or a placeholder report (e.g., reporter_node returns “Report generation failed, please retry later”) to avoid crashing the entire graph. However, if structured_profile is empty but still enters profile_analyzer_node, downstream RIASEC scores will all be zero—input thickness must be guaranteed at the Guide or Parser layer.

Pitfall 4: Implementation is Functions + Sub‑graphs, Not Agent Classes

All five nodes are async def xxx_node + run_xxx() entry points; there is no GuideAgent Python class. When reading the source, search for agents/guide.py‘s run_guide_agent, and do not interpret it as a CrewAI Role class.

6. Summary (iCan Orchestration Key Points)

  • The top level has only create_workflow() in workflow.py, with fixed node names: guide_noderoute_after_guide → four‑stage linear chain.
  • Each node follows four steps: outer TypedDict → inner TypedDict → run_xxx → map back to outer; field definitions are in core/state.py.
  • Two‑layer loop limits: inner should_continue (loop_count >= 8) + outer route_after_guide (user_msg_count >= 3).
  • The online SSE path uses run_guide_chat + run_analysis_pipeline, which differs from the CLI‑style run_workflow.

Next article: Engineering implementation of Holland RIASEC in agents/profile_analyzer.py, and structured scoring under OpenAI‑compatible APIs (DeepSeek etc. as .env deployment examples).


← Back to iCan Topic