• 其实这个项目几乎是我和我同学Vibe Coding出来的,但是完全依靠Vibe Coding也不行啊,还是要自己学点技术
  • 前端时间太忙了,杂七杂八的事一大堆,现在终于处理的差不多了,是时候要学学技术了~~
  • Vibe Coding是真会把人惯废
  • 大概讲一下我做这个项目的心路历程:

    • 首先,我是真的心累,各种意义上的心累,我的理想很美好,想做一个Live2D的二次元桌宠,然后让ta有自己的思维,可以帮我干活,可是途中遇到了各种各样的问题,真的是,遇到了一万个问题,真是燃尽我了,做不动了真的,所以后面的想法等放假再说吧...

    但其实整体上也算完成了当初想法的90%了吧,但是各种功能的效果真的都很一般...

  • 放几张效果图:
    2026-06-02T16:01:41.png
    2026-06-02T16:02:24.png
    2026-06-02T16:03:42.png
  • 我打算先从后端结构讲起,首先我不是什么技术大佬,写这个博客也只是为了促进自己的学习罢了,但其实也没人会看我的博客罢了,在这里,我向线下认识我,并且把我的博客盒了出来的朋友们问个好:你们好喵! 所以全网同名还是太容易盒了
  • 参考文献以及项目(感谢各位大佬们):
    (还参考了很多很多项目,后续会慢慢完善的,感谢各位大佬orz)

backend

Agent structure (simply)

  • 总得来说,我们的架构采用的是如下三层:
请求进来 → L1(判断意图) → L2(角色包装) → L3(执行工作) → 结果出去
                ↑                            │
                │         过程中              │
                └──── 消息管道 ───────────────┘
                      持续推送 quip/表情/状态
  • 第一层是router路由,第二层是roleplay,第三层就是类ReAct架构(但是很这个玩意很sb,因为把意图检查放在Layer1,就会很死板,之后可能还是要继续优化,如果把第三层做成完整ReAct架构应该会更好,但是那样有点难调试...)。
  • 其实这样做的目的也很简单:

    • 1.减轻上下文腐化现象,给roleplayworkflow层都留好干净的上下文
    • 2.更好的模拟出一个二次元形象
    • 3.大幅增强可拓展性
    • 4.当然含有很多很多好处...

Layer 0 预处理

整体架构:
                    POST /chat {"prompt": "帮我写个 calculator.py"}
                                    │
                                    ▼
┌───────────────────────────────────────────────────────────────────────┐
│                    routing_guard.route(prompt, context)               │
│                                                                       │
│  ┌─────────────────────────────────────────────────────────────┐     │
│  │  ① detect_intent(prompt)                                    │     │
│  │                                                              │     │
│  │  ┌──────────────────────┐                                    │     │
│  │  │ _is_empty_or_noise?  │──YES──▶ return "unknown"          │     │
│  │  └────────┬─────────────┘                                    │     │
│  │           │NO                                                │     │
│  │           ▼                                                  │     │
│  │  ┌──────────────────────────┐                                │     │
│  │  │ detect_run_action()      │                                │     │
│  │  │ 有UUID + retry/rerun/    │──YES──▶ return "coding"        │     │
│  │  │ cancel/inspect 关键词?    │          → run.retry/rerun/   │     │
│  │  └────────┬─────────────────┘         cancel/inspect        │     │
│  │           │NO                                                │     │
│  │           ▼                                                  │     │
│  │  ┌──────────────────────────────────────────────────────┐    │     │
│  │  │ looks_like_coding_prompt(prompt)                      │    │     │
│  │  │                                                       │    │     │
│  │  │  ╔══════════════════════════════════════════════════╗ │    │     │
│  │  │  ║ 第1层: 强信号 一票通过                           ║ │    │     │
│  │  │  ║  has_file_reference()    → .py .js .vue .json... ║ │    │     │
│  │  │  ║  _has_code_structure()   → def class import ...  ║ │    │     │
│  │  │  ║  命中任一 → "coding"                              ║ │    │     │
│  │  │  ╚══════════════════════════════════════════════════╝ │    │     │
│  │  │                       │未命中                          │    │     │
│  │  │                       ▼                                │    │     │
│  │  │  ╔══════════════════════════════════════════════════╗ │    │     │
│  │  │  ║ 第2层: 组合判断 (8个信号 → 3条规则)              ║ │    │     │
│  │  │  ║                                                  ║ │    │     │
│  │  │  ║  收集信号:                                       ║ │    │     │
│  │  │  ║    ├─ has_action           "写/改/删/运行..."    ║ │    │     │
│  │  │  ║    ├─ has_strong_action    "写/删/重构..."       ║ │    │     │
│  │  │  ║    ├─ has_object           "代码/文件/函数..."   ║ │    │     │
│  │  │  ║    ├─ has_deliverable      "demo/calculator..."  ║ │    │     │
│  │  │  ║    ├─ has_path             "src/components/..."  ║ │    │     │
│  │  │  ║    ├─ has_command          "pytest/npm/git..."   ║ │    │     │
│  │  │  ║    ├─ has_issue            "bug/报错/异常..."    ║ │    │     │
│  │  │  ║    └─ has_tech             "Python/Vue/React..." ║ │    │     │
│  │  │  ║                                                  ║ │    │     │
│  │  │  ║  规则A: 报错 + (对象|命令|技术)   → coding       ║ │    │     │
│  │  │  ║  规则B: 动作 + (对象|交付物|路径|命令) → coding  ║ │    │     │
│  │  │  ║  规则C: 强动作 + (技术|交付物)    → coding       ║ │    │     │
│  │  │  ╚══════════════════════════════════════════════════╝ │    │     │
│  │  │                       │均未命中                        │    │     │
│  │  │                       ▼                                │    │     │
│  │  │               return "chat"                            │    │     │
│  │  └──────────────────────────────────────────────────────┘    │     │
│  └─────────────────────────────────────────────────────────────┘     │
│                                                                       │
│                          ▼                                            │
│         ┌────────────────┼────────────────┐                          │
│         │                │                │                          │
│    intent=chat      intent=coding    intent=unknown                  │
│         │                │                │                          │
│         ▼                ▼                ▼                          │
│  ┌────────────┐  ┌──────────────┐  ┌──────────────┐                 │
│  │ for_chat() │  │_route_coding │  │ for_unknown()│                 │
│  │            │  │    (prompt,  │  │              │                 │
│  │ action=    │  │    context)  │  │ action=      │                 │
│  │ chat.reply │  └──────┬───────┘  │ final.answer │                 │
│  └────────────┘         │          └──────────────┘                 │
│                         ▼                                           │
│          ┌─────────────────────────────────────┐                    │
│          │ ② detect_run_action(prompt)         │                    │
│          │   有UUID+控制词?                     │                    │
│          │   YES → run.retry/rerun/cancel/inspect│                  │
│          │   NO  → _classify_coding_action()    │                    │
│          └──────────────┬──────────────────────┘                    │
│                         ▼                                           │
│          ┌─────────────────────────────────────┐                    │
│          │ ③ _classify_coding_action()         │                    │
│          │                                      │                    │
│          │  Tier 0: 写/删/移/复制               │                    │
│          │    → LLM 提取参数 + 生成文件内容      │                    │
│          │                                      │                    │
│          │  Tier 1: 测试/搜索                   │                    │
│          │    → LLM 提取参数                    │                    │
│          │                                      │                    │
│          │  Tier 2: 读文件                      │                    │
│          │    → 正则提取路径 → 失败 → LLM 兜底   │                    │
│          │                                      │                    │
│          │  Tier 3: 列目录                      │                    │
│          │    → 直接返回                        │                    │
│          │                                      │                    │
│          │  默认: run.create (代码生成)          │                    │
│          └──────────────┬──────────────────────┘                    │
│                         ▼                                           │
│          ┌─────────────────────────────────────┐                    │
│          │ ④ _extract_workspace_params_via_llm()│                   │
│          │                                      │                    │
│          │  system_prompt = EXTRACTION_PROMPT   │                    │
│          │  temperature  = 0.1                  │                    │
│          │  max_tokens   = 4000                 │                    │
│          │                                      │                    │
│          │  LLM 返回 JSON:                      │                    │
│          │  {"rel_path":"calc.py",              │                    │
│          │   "content":"def add(a,b):...",      │                    │
│          │   "overwrite":false}                 │                    │
│          └──────────────┬──────────────────────┘                    │
│                         ▼                                           │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │ ⑤ RoutingDecision(frozen=True)                               │   │
│  │                                                               │   │
│  │   intent       = "coding"                                     │   │
│  │   action_name  = "workspace.write"                            │   │
│  │   action_input = {"rel_path": "calculator.py",                │   │
│  │                   "content": "def add(a,b):\n    return a+b", │   │
│  │                   "overwrite": false}                          │   │
│  │   reason       = "Coding intent detected."                    │   │
│  │                                                               │   │
│  │   ⚠️ frozen: 创建后不可修改,L2只能读不能改                    │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                         │                                            │
└─────────────────────────┼────────────────────────────────────────────┘
                          │
                          ▼
              ┌───────────────────────┐
              │  传给 L2 RoleplayAgent │
              │  roleplay_agent        │
              │    .process(decision)  │
              └───────────────────────┘
  • 前端POST请求先送到api/chat_routes.py,由api/chat_routes.pyHTTP请求转换为函数参数,并且在最后后端流程跑完后,再由api/chat_routes.py将函数返回值转换成HTTP相应JSON
  • 大致原理:
HTTP 请求                                 HTTP 响应
{"prompt":"...","context":null,...}        {"ok":true,"intent":"coding",...}
        │                                         ▲
        ▼                                         │
┌──────────────────┐                    ┌──────────────────┐
│ req: ChatRequest │                    │ ChatResponse     │
│ (FastAPI 自动解析)│                    │ (FastAPI 自动序列化)│
└────────┬─────────┘                    └────────▲─────────┘
         │                                       │
         ▼                                       │
  req.prompt, req.context, req.session_id        │
         │                                       │
         ▼                                       │
  normalize_prompt_and_context()                 │
         │                                       │
         ▼                                       │
  generate_chat_response()  ──────────→  build_chat_response()
  • 我(codex)定义的解析规则为:
class ChatRequest(BaseModel):
    prompt: str = Field(..., min_length=1, description="用户输入")
    context: str | None = Field(default=None, description="对话上下文")
    session_id: str | None = Field(default=None, description="会话 ID;不传则由后端创建")
  • 随后由api/chat_routes.py调用services/chat.py ,services/chat.py 的功能则是用于按顺序调用其他模块,然后把结果拼成最终回复。
  • services/chat.py 文件结构:

┌─────────────────────────────────────────────┐
│  上半部分:数据定义                            │
│                                             │
│  class ChatServiceResult   ← 最终结果的容器    │
│                                             │
│  helper 函数:                                │
│    _apply_scheduled_output                  │
│    _schedule_coding_run_if_needed            │
│    _persist_result_to_conversation           │
└─────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────┐
│  下半部分:主函数                              │
│                                             │
│  generate_chat_response()                   │
│    9 个步骤,串联所有模块                      │
└─────────────────────────────────────────────┘
  • 所以其实后端整体的工作流控制是由services/chat.py 中的generate_chat_response()进行的,class ChatServiceResult则就是我前面说的,将函数返回值转换成响应JSON的结果容器。
  • generate_chat_response()流程图
generate_chat_response(prompt, context, session_id, schedule_run)
    │
    ├─ 步骤1: 会话管理
    ├─ 步骤2: 上下文拼接
    ├─ 步骤3: L1预判 (拿 intent_hint)
    ├─ 步骤4: 发聊天开始反馈
    ├─ 步骤5: 文件上下文
    ├─ 步骤6: L1正式路由
    ├─ 步骤7: L2+L3执行 (线程池)
    ├─ 步骤8: Hermes记忆
    └─ 步骤9: 构建结果 + 调度 + 持久化
步骤1: 会话管理
  • 主要目的是确定会话ID

    active_session_id = conversation_store.get_or_create_session_id(session_id)
  • 前端传了 session_id ──→ 复用已有会话,加载历史消息
  • 前端没传 session_id ──→ 创建新会话,返回新 UUID
  • conversation_store 操作 backend/workspace/conversations/ 下的 JSON 文件。每个会话一个文件。
步骤2: 上下文拼接
  • 用的笨办法,直接拼接字符串
effective_context = conversation_store.build_context(active_session_id, context)
memory_context = hermes_memory.build_context(active_session_id)

if memory_context:
    if effective_context:
        effective_context = f"{memory_context}\n\n---\n\n{effective_context}"
    else:
        effective_context = memory_context
  • 对于处理对话历史 memory_context:
  • build_context(session_id, external_context)去提取记忆数据,随后调用排版引擎函数_build_context_text(messages, metadata, external_context),并按固定顺序拼接三个段落:
  • 对于处理当前对话记忆memory_context:
  • 使用结构化记忆,随后再格式化输出
@dataclass
class MemoryEvent:
    turn_index: int         # 第几轮
    timestamp: str          # 时间戳
    user_input: str         # 用户说了什么(截断200字符)
    intent: str             # L1 路由判断的意图(chat / coding / file_ops / ...)
    action_name: str        # 执行了什么动作(run_code / edit_file / ...)
    result_summary: str     # 执行结果摘要(截断200字符)
    ok: bool                # 成功还是失败
    error: str | None       # 错误信息

@dataclass
class MemoryEvent:
    turn_index: int         # 第几轮
    timestamp: str          # 时间戳
    user_input: str         # 用户说了什么(截断200字符)
    intent: str             # L1 路由判断的意图(chat / coding / file_ops / ...)
    action_name: str        # 执行了什么动作(run_code / edit_file / ...)
    result_summary: str     # 执行结果摘要(截断200字符)
    ok: bool                # 成功还是失败
    error: str | None       # 错误信息
┌──────────────────────────────────────┐
│ ① Client provided context            │  ← 前端传的额外上下文(可选)
│    (截断到 2000 字符)               │
├──────────────────────────────────────┤
│ ② Compressed earlier summary         │  ← 旧消息的压缩摘要
│    covering N older messages         │
├──────────────────────────────────────┤
│ ③ Recent stored conversation history │  ← 最近8条消息原文
│    User: xxx                         │
│    Assistant: xxx                    │
└──────────────────────────────────────┘
  • 最后将两种记忆选择性合并

    ┌─────────────────┐     ┌─────────────────┐
    │ Hermes 长期记忆   │     │ 会话历史消息      │
    │ (跨会话,结构化)   │     │ (当前会话,N条)    │
    │                 │     │                 │
    │ "用户讨厌Java    │     │ user: 帮我看文件  │
    │  偏好Python     │     │ assistant: 好的   │
    │  上次写了fib.py" │     │ user: 改一下     │
    └────────┬────────┘     └────────┬────────┘
           │                       │
           └───────────┬───────────┘
                       ▼
           "用户讨厌Java,偏好Python...
            
            ---
            
            user: 帮我看文件
            assistant: 好的
            user: 改一下"
  • 结构简单点,但是感觉很容易造成上下文腐化,并且如果记忆太多,会加大token的消耗,总的来说还是太史了。
  • 如果换成现在常用的结构化消息流应该会好一些。
步骤3: L1预判 (拿 intent_hint)
  • 这一块貌似有点没必要?
  • 步骤3 + 步骤6:L1 被调用了两次,对比一下:

    • 步骤3: 第1次调用 —— 只为了快速知道是chat还是coding
    • intent_hint = routing_guard.route(prompt, context).intent
    • 步骤6: 第2次调用 —— 正式路由,带完整上下文
    • decision = routing_guard.route(prompt, effective_context, file_context)
  • 这里大致讲一下三个context的区别(虽然都是AI给的罢了):
    2026-06-04T05:43:30.png
  • 第一次调用L1是为了进行快速的嗅探, 仅靠prompt和前端的现场提示判断意图,随后注入到上下文中。
  • 主要目的就是为了在拿到完整的上下文之前发出一个提示性quip,输出一个意图提示的quip。但这个我感觉完全没有必要...
步骤4: 发聊天开始反馈
  • 写死的一个状态显示,用于开启绿灯闪烁和提示前端气泡,进行一个运行状态的证明
def send_chat_started():
    return dispatch_character_event(CHAT_STARTED_EVENT)

CHAT_STARTED_EVENT = CharacterEvent(
    quip="我想一下...",         # → MessageQueue → 前端气泡
    expression="thinking",     # → MessageQueue → Live2D 表情
    status="running",          # → MessageQueue → 绿点闪烁
    progress=5,
)
步骤5: 文件上下文
  • 用于获取同一会话中之前操作过的文件记录,形成文件上下文,为第二次L1调用做好准备。
file_context = None
from ..storage.file_context_store import file_context_store
stored_fc = file_context_store.get_context(active_session_id)
if stored_fc:
    file_context = stored_fc
步骤6: L1正式路由
  • 这个放在后面讲
步骤7: L2+L3执行 (线程池)
  • 一样后面一起讲
步骤8: 记录到Hermes记忆
hermes_memory.record_turn(
    session_id=active_session_id,
    user_input=prompt,
    intent=decision.intent,
    action_name=decision.action_name,
    result_summary=process_result.response.chat_line[:200],  # 截前200字
    ok=True,
)
  • 大语言模型本身是无状态的,所以要保存记忆,这段代码记录了一次完整的交互轨迹。

    • user_input:用户的原始触发条件。
    • intent & action_name:Agent 大脑解析出的意图和调用的工具/行为。
    • ok=True:该动作执行的最终状态。
  • 例如:当 Agent 下一次需要回忆时,这段结构化数据可以让系统知道:“用户上次让我查天气(intent),我调用了天气 API(action_name),并且成功了(ok=True)。”
步骤9: 构建结果 + 调度 + 持久化
L2+L3 线程池返回 process_result
    │
    │  process_result = ProcessResult(
    │      response = RoleplayResponse(chat_line="搞定啦!已创建文件 ⭐", expression="proud", quip="机魂大悦!", ...)
    │      work_metadata = {"run_id":"abc-123", "run_action":"create", "stop_reason":"done", ...}
    │  )
    │
    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                     services/chat.py 收尾四步                                 │
│                                                                              │
│  ① Hermes 记忆记录                                                           │
│     hermes_memory.record_turn(session_id, prompt, intent, action, result...) │
│     → 写入结构化日志: [Turn 3] OK | Intent: coding | Action: workspace.write │
│                                                                              │
│  ② 构建 ChatServiceResult                                                    │
│     从 ProcessResult 中拆解出: run_id, run_action, chat_line, ok, error      │
│     组装成 ChatServiceResult 对象                                            │
│                                                                              │
│  ③ 调度后台执行 (_schedule_coding_run_if_needed)                              │
│     如果 coding + 有 run_id + 有调度器 → 放入后台队列执行                    │
│                                                                              │
│  ④ 持久化到对话存储 (_persist_result_to_conversation)                         │
│     conversation_store.append_message(session, "user", prompt)                │
│     conversation_store.append_message(session, "assistant", output)           │
│     发 "聊天结束" 信号 (send_chat_done / send_chat_failed)                    │
│                                                                              │
│  return result.attach_session(active_session_id)                             │
└─────────────────────────────────────────────────────────────────────────────┘
  • 然后留着后台继续跑调度器,待调度器结束后,通过WebSocket把结果推送至前端
schedule_run("abc-123")
    │
    │  FastAPI background_tasks.add_task(execute_run, "abc-123")
    │
    ▼
┌────────────────────────────────────────────────────────────┐
│              execute_run("abc-123")  后台线程                │
│                                                            │
│  循环:                                                     │
│    ├─ 生成代码 → 写入文件 → 执行脚本                         │
│    ├─ 成功? → finalize_success                             │
│    ├─ 失败? → LLM自动修复 → 重试 (最多N次)                   │
│    └─ 用户取消了? → finalize_cancelled                      │
│                                                            │
│  无论如何结束:                                               │
│    ├─ update_run_record(run_id, status="done"/"failed")    │
│    │   → 状态写入磁盘                                       │
│    │                                                       │
│    ├─ send_task_done() / send_task_failed()                │
│    │   → 通过 WebSocket 推给前端: "任务完成/失败"             │
│    │                                                       │
│    └─ emit_final_run_chat_message()                        │
│        → 用 Shion 的口吻把执行结果推送到聊天窗口              │
│        → "代码跑完啦!生成了 3 个文件 ⭐"                    │
│                                                            │
└────────────────────────────────────────────────────────────┘

Layer 1: Router 路由门卫

  • 不执行任何工作,主要用于意图判断。

在三层架构中的位置
POST /chat { prompt: "帮我写个排序", context: "当前在 app.ts" }
    │
    ▼
┌─────────────────────────────────────────────────┐
│          services/chat.py                       │
│                                                 │
│  ① 拼接上下文                                    │
│  ② ★ L1: decision = routing_guard.route(...)    │
│  ③ L2: roleplay_agent.process(decision)         │
│  ④ L3: work_agent.execute(decision) — L2内部调用 │
│  ⑤ 构建结果 + 持久化 + 返回                      │
└─────────────────────────────────────────────────┘

L1 被 L2 和 L3 依赖,但它不依赖任何一层。它在调用链的最前面,是一次纯判断。


核心架构
routing_guard.route(prompt, effective_context, file_context)
    │
    ▼
┌───────────────────────────────────────────────────────┐
│                   RoutingGuard                        │
│                                                       │
│  def route(self, prompt, context, file_context):      │
│      intent = detect_intent(prompt)     ← 引擎①       │
│                                                       │
│      if intent == "chat":                             │
│          → for_chat(prompt, context)    ← 工厂②       │
│                                                       │
│      if intent == "coding":                           │
│          → _route_coding(prompt, context) ← 分流③     │
│              │                                        │
│              ├─ 运行控制? → run.retry/rerun/cancel     │
│              │                                        │
│              └─ 新请求 → _classify_coding_action()    │
│                      │               ← 分类器④        │
│                      ├─ Tier1: write/delete/move/copy │
│                      │   → LLM提取参数                 │
│                      ├─ Tier2: test/search            │
│                      │   → LLM提取参数                 │
│                      ├─ Tier3: read                   │
│                      │   → 正则优先,LLM兜底           │
│                      ├─ Tier4: list                   │
│                      │   → 直接执行                    │
│                      └─ Default: run.create           │
│                          → 兜底                        │
│                                                       │
│      if intent == "unknown":                          │
│          → for_unknown(prompt)         ← 兜底⑤        │
│                                                       │
│      输出: RoutingDecision (frozen, 不可变)             │
└───────────────────────────────────────────────────────┘
  • 整个 L1 由五个组件构成:一个意图引擎、一个 chat 工厂、一个 coding 分流器、一个四级动作分类器、一个 unknown 兜底。

引擎①:detect_intent() —— 意图判定
  • 这是 L1 的第一次意图匹配。纯关键词 + 正则匹配,不调 LLM,零延迟。
detect_intent(prompt)
    │
    ├─ ① 空消息 / 纯符号?
    │     └→ "unknown"
    │
    ├─ ② 包含运行控制动作?(retry / rerun / cancel / inspect)
    │     └→ "coding"    ← 只要在操作 run,一定是编程场景
    │
    ├─ ③ looks_like_coding_prompt()?
    │     └→ "coding"
    │
    └─ ④ 都不是
          └→ "chat"
  • 核心判定函数 looks_like_coding_prompt() 用的是组合匹配策略:
输入: "帮我写一个 React 组件"

├─ 有文件名引用?(xxx.py, xxx.vue)     → 没命中
├─ 有代码结构?(def, class, import)     → 没命中
│
└─ 组合判定:
    ├─ 有动作词 "写"        ✓
    ├─ 同时有产物对象 "组件"  ✓
    └─ 两个条件同时满足      → coding ✅

输入: "今天天气真好"

├─ 没文件名
├─ 没代码结构
├─ "天气" 不是动作词
└─ "真好" 不是技术对象      → chat ✅
  • 为什么必须组合匹配? 单一关键词不可靠。"写"字可能出现在"写一封信"里。必须动作词 + 技术对象 同时出现才判为 coding。这是整个路由系统防误判的核心。但是后续我测试了几种情况,也会判断失误,而且会出现一些很奇怪的问题,不够我也改不动了
  • 关键词检测函数:
函数检测内容示例命中
_has_coding_action()动作词写、改、修、生成、创建、run、test、fix
_has_workspace_object()工作对象代码、文件、模块、组件、函数、bug
_has_deliverable_object()产物对象demo、示例、页面、工具、插件
_has_tech_context()技术上下文python、vue、react、fastapi、git
has_file_reference()文件名正则app.tsutils.py
_has_issue_keyword()错误关键词bug、报错、异常、traceback、failed

工厂②:for_chat() —— chat 快速打包
def for_chat(cls, prompt, context):
    return RoutingDecision(
        intent="chat",
        action_name="chat.reply",       # 固定动作,不需要拆解
        action_input={"prompt": prompt, "context": context},
        reason="Chat intent detected.",
    )
  • chat 是最简单的路径——不需要拆解具体动作,不需要提取参数。把原始 prompt 和上下文原样打包,L2 直接用自己的聊天人格和 LLM 对话生成回复。

分流③:_route_coding() —— 旧任务 vs 新请求
def _route_coding(self, prompt, context):
    run_action = detect_run_action(prompt)
    
    # 分支A: 用户在操作已有任务
    if run_action != "create":
        target_run_id = extract_run_reference(prompt)
        return RoutingDecision.for_coding(
            action_name={
                "retry":   "run.retry",    # "重试一下 abc-123"
                "rerun":   "run.rerun",    # "重新跑 abc-123"  
                "cancel":  "run.cancel",   # "取消 abc-123"
                "inspect": "run.inspect",  # "看看 abc-123 的进度"
            }[run_action],
            action_input={"run_id": target_run_id},
        )
    
    # 分支B: 全新的编程请求 → 进入动作分类器
    action_name, action_input, reason = _classify_coding_action(prompt, context)
    return RoutingDecision.for_coding(...)
  • 先判断是不是旧任务操作,如果是就跳过动作分类,直接走 run 控制。不是旧任务才进入四级分类器。

分类器④:_classify_coding_action() —— 四级分级
  • 这是 L1 最核心的函数。把 coding 意图拆解为具体动作 + 结构化参数
_classify_coding_action(prompt, context)
    │
    │  关键词匹配 → 命中哪层就进哪层,不往下走
    │
    ├── Tier 1: 破坏性写操作 → 必须 LLM 提取参数
    │   ├─ "写/生成/创建/实现"       → workspace.write
    │   ├─ "删除/remove/rm"         → workspace.delete
    │   ├─ "移动/重命名/move"       → workspace.move
    │   └─ "复制/拷贝/copy"         → workspace.copy
    │
    ├── Tier 2: 复杂只读操作 → LLM 提取参数
    │   ├─ "测试/pytest/test"       → workspace.test
    │   └─ "搜索/查找/grep/find"    → workspace.search
    │
    ├── Tier 3: 简单读取 → 正则优先,失败才用 LLM
    │   └─ "读/打开/查看/read"      → workspace.read
    │        ├─ 正则能提到路径 → 直接用(省一次 LLM 调用)
    │        └─ 提不到 → LLM 提取
    │
    ├── Tier 4: 零参数操作 → 直接执行
    │   └─ "列出/目录/ls/tree"      → workspace.list
    │
    └── Default: 兜底
        └─ 都不命中 → run.create   (当普通代码运行)
  • Tier 分级的逻辑
Tier动作类型为什么用 LLM?
1write / delete / move / copy破坏性操作,必须精确拿到文件路径和完整内容
2test / search参数复杂(搜什么、哪个目录、哪个测试文件)
3read大部分情况正则就能从 prompt 抓到路径,省一次 LLM 调用
4list完全不需要参数,跳过 LLM 直接执行
  • LLM 参数提取——当 Tier 1/2 命中(或 Tier 3 正则失败)时触发:
用户: "帮我在 src/utils 下写一个 helper.py"

_extract_workspace_params_via_llm(prompt, context, "workspace.write")
    │
    ├─ System Prompt: "你是参数提取器,只返回 JSON,不要废话"
    ├─ User Message:  "Action: workspace.write
    │                  User request: 帮我在 src/utils 下写一个 helper.py"
    ├─ call_llm_sync()  ← 轻量模型、低温、小 token 数
    │
    └─ LLM 返回:
        {
          "action": "workspace.write",
          "rel_path": "src/utils/helper.py",
          "content": "# Helper utilities\n\ndef ...",
          "overwrite": false
        }

  • LLM提参数:

    • 意图判定(chat / coding)用关键词就够了,0ms,0 token
    • 参数提取("src/utils/helper.py"的精确路径、完整文件内容)关键词做不到,必须 LLM
    • 因为关键词匹配可以判断出当前需要做的工作,但是无法确定除具体要写什么,这时候就要调用LLM提取出具体的参数。
  • call_llm_sync()本身是一个通用的 LLM 调用函数,用于参数提取只是他的使用场景之一,他在整个项目中被多次用到,但是每次的用途不一样,传入的system_prompt不一样,因此效果也不一样,LLM的行为不一样。
call_llm_sync(prompt="...", system_prompt=???)

2026-06-04T09:38:27.png

  • 具体情况如上

兜底⑤:for_unknown()
def for_unknown(cls, prompt):
    return RoutingDecision(
        intent="unknown",
        action_name="final.answer",
        action_input={"content": "本机不太确定你想做什么呢...能再说清楚一点吗?"},
    )
  • 触发条件:空消息、纯符号、乱码。让 L2 返回一句友好的引导语,不会静默失败。

产出:RoutingDecision
@dataclass(frozen=True, slots=True)  # frozen = 不可变
class RoutingDecision:
    intent: str               # "chat" | "coding" | "unknown"
    action_name: str          # "chat.reply" | "workspace.write" | "run.create" | ...
    action_input: dict        # {"prompt":..., "rel_path":..., "content":..., ...}
    reason: str               # 为什么这么判(调试用)
    metadata: dict            # 额外信息(run_id 等)

frozen=True 的意义:L1 判决完后,L2 和 L3 只能读不能改。防止下游代码意外污染路由决策,也防止并发问题。


设计原则
① 纯分类
   → 不执行任何工作,不调工具,只产出决策
   → 职责边界清晰,L1 绝不越界干活

② 关键词优先
   → 意图判定不用 LLM(快、免费、可预测、可调试)
   → 大模型留给真正需要语义理解的参数提取场景

③ LLM 最小化使用
   → 仅在关键词无法胜任的结构化参数提取时调用
   → 用了也是小模型 + 低温 + 小 token 数,把成本压到最低

④ 逐级降级
   → LLM 失败 → 正则兜底
   → 正则也失败 → run.create 最终兜底
   → 每一级都有 fallback,不会因为一个环节挂了就让整个请求失败

总的来说

L1 是一个路由层。它用关键词做快速分类,用 LLM 做参数提取,用逐级降级保证鲁棒性。产出的 RoutingDecision 是不可变对象,下游只消费不管修改。整个系统的"理解用户意图"这件事,全压缩在这一层里。

Layer 2: Roleplay — 角色扮演层

L2接收 L1 的路由决策,调用 L3 干活,然后把L3返回的结果包装成 Shion 的口吻,推送到前端。


核心流程
process(decision, session_id, memory_context)
    │
    ├─ chat 路径
    │   ├─ _handle_chat()
    │   │   ├─ 拿 decision.action_input 里的 prompt + context
    │   │   └─ call_llm_sync() → CHAT_SYSTEM_PROMPT(纯聊天人格)
    │   │
    │   └─ 返回 ProcessResult(不调 L3)
    │
    └─ coding 路径
        ├─ work_agent.execute(decision)  → L3 返回 work_result(dict)
        ├─ _generate_persona_response()  → 把 dict 包装成 Shion 口吻
        │   ├─ 失败 → 硬编码返回 sad 表情(不浪费 token)
        │   └─ 成功 → 调 LLM,填 ROLEPLAY_SYSTEM_PROMPT + state_context
        ├─ mood 更新(连续成功→开心,连续失败→沮丧)
        ├─ _emit_to_frontend() → 推 4 路消息到前端
        └─ 返回 ProcessResult(response, work_metadata)

两个 System Prompt

CHAT_SYSTEM_PROMPT — 纯聊天用,简化版,没有状态占位符:

CHAT_SYSTEM_PROMPT = """
# 角色设定
你是"Shion"——一个寄宿在用户桌面上的 AI 智能体桌宠。
...(性格、语气、输出JSON格式要求)...
"""
  • ROLEPLAY_SYSTEM_PROMPT — 工作结果包装用,带两个动态占位符:
ROLEPLAY_SYSTEM_PROMPT = """
# 角色设定
你是shion——...

当前状态上下文:
{state_context}        ← 填入 L3 的工作结果摘要

当前情绪倾向:
{mood_modifier}        ← 填入当前情绪描述

请生成回复。只输出 JSON!
"""
  • 调用前动态填入:
state_context = "意图: coding\n动作: workspace.write\n结果: 成功\n输出: 已创建 sort.py\n步数: 3"
mood_modifier = "你现在心情很好,充满能量。可以欢呼、用颜文字。"

system_prompt = ROLEPLAY_SYSTEM_PROMPT.format(
    state_context=state_context,
    mood_modifier=mood.modifier_text,
)
  • 两套 prompt 共享同一套角色人设,区别是聊天不需要任务状态,工作包装需要。

_generate_persona_response() — 角色包装
  • 输入是 L3 返回的 dict:
work_result = {
    "ok": True,
    "summary": "已创建文件 src/sort.py",
    "output": "def bubble_sort(arr): ...",
    "metadata": {"run_id": "abc-123", "step_count": 3}
}
  • 处理逻辑:
① L3 失败? → 不调 LLM,直接返回 RoleplayResponse(expression="sad", quip="唔...失败了")

② L3 成功 → 构建 state_context → 填 system_prompt → call_llm_sync()
    → LLM 返回 JSON: {"chat_line":"搞定啦!⭐","expression":"proud","quip":"机魂大悦!"}

前端发送
_emit_to_frontend(response, ...)
    ├─ send_chat_message(chat_line)   → 聊天窗口正文
    ├─ send_expression(expression)    → Live2D 表情变化
    ├─ send_quip(quip)               → 模型上方浮动气泡
    └─ send_motion(motion)           → Live2D 动作组
  • 四条消息异步并发推送。chat 路径用更简约的 _emit_chat_to_frontend(),只发消息和表情,不发气泡和动作。

情绪系统
  • 全局单例 _session_mood = RoleplayMood(),跨请求累积:

    • 连续成功 ≥3 → happy → "你现在心情很好,充满能量。可以欢呼、用颜文字。"
    • 连续失败 ≥3 → frustrated → "你现在有点烦躁,语气可以带刺,嘴硬心软。"
    • 连续失败 1-2 → tired → "你现在有点累了...语气稍微慵懒,带点委屈。"
    • 闲置 ≥5 轮 → lonely → "你现在感到寂寞...主动寻求关注,但不过度。"
    • 其他 → neutral → "心态平稳,自然放松。"

每次 process() 结束时根据 ctx.scenario()mood.record_success() / record_failure() / record_neutral()。下一轮请求的 mood_modifier 就会变,Shion 的语气就跟着变。


输出结构
ProcessResult(
    response = RoleplayResponse(
        chat_line,    # 聊天文本
        expression,   # Live2D 表情名
        quip,         # 气泡短句
        motion,       # Live2D 动作组
    ),
    work_metadata = {run_id, run_action, step_count, ...}  # 给调度器用
)
  • 回到 services/chat.pychat_line → HTTP 响应,work_metadata → 调度 + 持久化。

Layer 3: WorkAgent — workflow

L3接收 L2 传来的路由决策,执行具体的工作,返回结构化结果。不做任何角色扮演,不推前端消息。


在三层中的位置
L2: roleplay_agent.process()
    │
    ├─ chat 意图 → L2 自己处理,不调 L3
    │
    └─ coding 意图 → work_agent.execute(decision)  ← L3 入口
        │
        ├─ 普通文件操作 (write/read/list/delete...)
        │     → action registry 直接执行
        │
        └─ run.create(生成+运行代码)
              → LangGraph 子图:PM → Coder → Executor → QA → Debugger

work_agent.execute() — 入口
def execute(self, decision, *, session_id, turn_id, memory_context):
    if decision.intent == "chat":
        return self._chat_result(decision)    # {"ok": True, "summary": ""}

    if decision.intent == "coding":
        return self._execute_loop(decision, ...)   # LangGraph 循环

    return {"ok": False, "error": "Unknown intent"}
  • chat 意图不经过 L3,L2 已经从 process() 里截断了。coding 意图进 _execute_loop()

_execute_loop() — LangGraph 循环
def _execute_loop(self, decision, *, session_id, turn_id, memory_context):
    # 拼 prompt + context
    prompt = decision.action_input.get("prompt")
    context = decision.action_input.get("context")
    if memory_context:
        context = f"{memory_context}\n\n---\n\n{context}"

    # 启动 LangGraph
    result = run_agent_loop(
        prompt=prompt,
        context=context,
        action_name=decision.action_name,    # "workspace.write"
        action_input=decision.action_input,  # {rel_path, content, ...}
        session_id=session_id,
    )

    # 从 state 提取结果
    return {
        "ok": result.state["action_result"]["ok"],
        "summary": result.state["output"],
        "output": result.state["output"],
        "metadata": {run_id, run_action, step_count, stop_reason, ...}
    }

LangGraph 图结构
                ┌──────────────┐
                │  plan_node   │  ← 入口:把 L1 的路由决策转成执行计划
                └──────┬───────┘
                       │
                       ▼
                ┌──────────────┐
                │   act_node   │  ← 核心:执行动作
                └──────┬───────┘
                       │
                       ▼
                ┌──────────────┐
                │ observe_node │  ← 观察结果,标记 done=True
                └──────┬───────┘
                       │
                       ▼
                ┌──────────────────┐
                │decide_continue   │  ← 判断:结束了还是继续循环?
                └──┬───────────┬───┘
                   │           │
          done=True│           │done=False
                   ▼           ▼
         ┌──────────┐   ┌──────────┐
         │finalize  │   │ plan_node │ ← 重新计划(目前一次就停)
         └──────────┘   └──────────┘
  • 现在是单轮循环——observe_node 里直接 done = True,执行完一个动作就结束了。

act_node — 动作分发
def _execute_selected_action(state):
    action_name = state["action_name"]

    # 分支1: run.create → 进入 coding 子图(PM→Coder→Executor→QA→Debugger)
    if action_name in {"run.create"}:
        return _execute_action_via_coding_workflow(state)

    # 分支2: 普通文件操作 → 走 action registry
    return default_action_registry.execute(action_name, state["action_input"])
action_name走向做什么
workspace.writeaction registry把 content 写入文件
workspace.readaction registry读取文件内容并返回
workspace.listaction registry列出目录结构
workspace.deleteaction registry删除文件
workspace.searchaction registry搜索文件内容
run.createcoding 子图生成代码 → 写入 → 执行 → 测试 → 修复

Coding 子图 --run.create
  • 用户说"帮我写个 Python 脚本"时,L1 判定为 run.create,L3 进入这个子图:
          ┌──────────┐
          │ pm_node  │  PM(项目经理):调 LLM,把需求拆成编码任务
          └────┬─────┘
               │
               ▼
          ┌──────────┐
          │coder_node│  Coder(程序员):调 LLM,生成代码
          └────┬─────┘
               │
               ▼
          ┌──────────────┐
          │executor_node │  Executor(执行器):把代码写入文件并运行
          └────┬─────────┘
               │
               ▼
          ┌──────────┐
          │  qa_node │  QA(测试):检查执行结果,判断是否通过
          └────┬─────┘
               │
          ┌────┴────┐
          │         │
       通过?      失败?
          │         │
          ▼         ▼
    ┌─────────┐  ┌──────────────┐
    │ finish  │  │ debugger_node│  Debugger(调试员):调 LLM 分析错误并修复
    └─────────┘  └──────┬───────┘
                        │
                        ▼
                   ┌──────────┐
                   │coder_node│ ← 回到 Coder 重新生成修复后的代码
                   └──────────┘
  • 这五个角色全是同一套 LLM + 不同的 system prompt。简单来说就是PM 做任务拆解,Coder 写代码,Executor 运行,QA 检查结果,Debugger 分析错误。

engine.py 中的 WORK_SYSTEM_PROMPT
WORK_SYSTEM_PROMPT = """你是 Layer 3 工作引擎。
只负责纯粹的代码执行和文件操作,不承担任何角色扮演。

核心规则:
1. 专注任务:只关注用户的实际工作需求,忽略闲聊
2. 文件操作:所有读写都在安全的 workspace 内进行
3. 结果导向:执行完成后给出结构化的结果输出
4. 错误透明:遇到错误时如实报告,不含糊

输出格式: {"ok": true|false, "summary": "...", "output": "...", "error": "..."}
"""
  • 这个 prompt 让 L3 只说工作数据,返回 {"ok": true, "summary": "文件已创建"} 这纯技术输出。

L3 返回给 L2 的结果
{
    "ok": True,                        # 执行是否成功
    "intent": "coding",
    "summary": "已创建文件 src/sort.py", # 人类可读摘要
    "action_name": "workspace.write",
    "output": "def bubble_sort(arr):...", # 完整输出
    "error": None,
    "file_context": {"recent_files": [...], "last_modified": "..."},
    "metadata": {
        "run_id": "abc-123",
        "run_action": "create",
        "step_count": 3,
        "stop_reason": "completed"
    }
}
  • 这个 dict 回到 L2 后,L2 用 _generate_persona_response() 把它包装成 Shion 的口吻。

三层之间的通信协议

  • L1、L2、L3 在同一进程内运行,之间传 Python 对象,没有序列化开销。每一层只暴露一个入口函数,输入和输出都有明确的类型。

通信拓扑
                    L1: RoutingGuard
                    route(prompt, context, file_context)
                           │
                           │  RoutingDecision (frozen dataclass)
                           ▼
                    L2: RoleplayAgent
                    process(decision, session_id, memory_context)
                      │                    │
                      │ chat → 自己处理     │ coding → 调 L3
                      │                    │
                      │                    │  RoutingDecision (同一个对象)
                      │                    ▼
                      │           L3: WorkAgent
                      │           execute(decision, session_id, ...)
                      │                    │
                      │                    │  dict {ok, summary, output, metadata}
                      │                    ▼
                      │           L2 拿回结果 → 角色包装 → 前端发射
                      │                    │
                      ◄────────────────────┘
                           │
                           │  ProcessResult (response + work_metadata)
                           ▼
                    services/chat.py

① L1 → L2:RoutingDecision
  • 谁调谁services/chat.py 调 L1,拿到 RoutingDecision,然后传给 L2。
# services/chat.py 第216-231行
decision = routing_guard.route(prompt, effective_context, file_context)
#         ↑ L1 的入口函数

process_result = await anyio.to_thread.run_sync(
    partial(roleplay_agent.process, decision, ...)
    #                                ↑ 同一个对象传给 L2
)
  • 传的是什么
@dataclass(frozen=True)  # 不可变,L2/L3 只能读不能改
class RoutingDecision:
    intent: str              # "chat" | "coding" | "unknown"
    action_name: str         # "chat.reply" | "workspace.write" | "run.create" | ...
    action_input: dict       # {prompt, context, rel_path, content, ...}
    reason: str              # 调试用
    metadata: dict           # 额外信息
  • frozen:防止 L2 或 L3 意外修改决策。L1 判完就封存。

② L2 → L3:同一个 RoutingDecision
  • L2 的 process() 方法调 L3 的 execute()
# roleplay.py 第621-626行
work_result = self.work_agent.execute(
    decision,              # ← L1 产出的同一个 RoutingDecision 对象,原样传入
    session_id=session_id,
    turn_id=turn_id,
    memory_context=memory_context,
)
  • L2 不对 decision 做任何修改,直接转发。L2 的角色只是"中间人"——聊天时自己处理,coding 时转给 L3。

③ L3 → L2:dict
  • L3 的 execute() 返回一个 dict 给 L2。
# engine.py WorkAgent._execute_loop() 返回
return {
    "ok": True,
    "intent": "coding",
    "summary": "已创建文件 src/sort.py",
    "action_name": "workspace.write",
    "output": "def bubble_sort(arr): ...",
    "error": None,
    "file_context": {"recent_files": ["src/sort.py"]},
    "metadata": {
        "run_id": "abc-123",
        "run_action": "create",
        "step_count": 3,
        "stop_reason": "completed",
    },
}
  • 为什么用 dict 而不是 dataclass:L3 是纯工作引擎,不应该依赖 L2 的数据结构。dict 是通用契约,L2 自己知道怎么解析。
  • L2 拿到后:

    • work_result 提取字段 → 拼 state_context → 调 LLM 做角色包装
    • work_result["metadata"] 提取 run_id/run_action → 放入 ProcessResult.work_metadata

④ L2 → services:ProcessResult
# roleplay.py
@dataclass
class ProcessResult:
    response: RoleplayResponse      # 角色包装后的内容
    work_metadata: dict[str, Any]   # L3 的元数据

@dataclass
class RoleplayResponse:
    chat_line: str      # "搞定啦!已创建 sort.py ⭐"
    expression: str     # "proud"
    quip: str           # "机魂大悦!"
    motion: str         # ""
    mood_label: str     # "happy"
    scenario: str       # "success"
  • 回到 services/chat.py 后:
# response.chat_line → HTTP 响应正文
# work_metadata → 调度后台执行 + 持久化
result = ChatServiceResult(
    output=process_result.response.chat_line,
    run_id=process_result.work_metadata.get("run_id"),
    ...
)

数据流汇总
L1 产出        L2 消费               L3 消费
─────────────────────────────────────────────────
RoutingDecision ──→ RoutingDecision ──→ RoutingDecision
                   (chat:自己用)        (coding:执行)
                   
                   L3 产出
                   ────────
                   dict {ok, summary, output, metadata}
                        │
                        ▼
                   L2 消费 → 角色包装
                        │
                        ▼
                   ProcessResult(response, work_metadata)
                        │
                        ▼
                   services/chat.py
                   ├─ response.chat_line → HTTP
                   └─ work_metadata → 调度+持久化

设计要点
① 同进程传对象 → Python 对象直接传,不走网络,不走 JSON
② 单向依赖 → L1 → L2 → L3,后层不调前层
③ frozen 防篡改 → RoutingDecision 创建后不可改
④ dict 解耦 → L3 不依赖 L2 的数据结构
⑤ L2 是唯一枢纽 → 所有路径都经过 L2,它是三层的"调度中心"