本文描述当前 Agent 的路由模式:如何用 LLM Tool Calling 替代关键词意图识别,在本地工作区沙箱内完成多步 ReAct,再把真实数据交给对话模型生成最终回复。
1. 设计动机
早期实现用 thoughtNode + 关键词枚举判断用户意图(列目录、读文件、搜代码等)。这种方式有三个根本问题:
| 问题 | 表现 |
|---|---|
| 意图覆盖不全 | 新说法、口语化追问(「列给我」「那个项目呢」)容易漏判 |
| 上下文断裂 | 多轮对话中的指代(「它」「上面那个」)难以用规则处理 |
| 维护成本高 | 每加一种场景就要改关键词表,逻辑膨胀且互相干扰 |
当前方案采用业界主流的 LLM Tool Calling + ReAct 循环 :由模型根据 system prompt 和工具 schema 自行决定 是否调用工具、调用哪个、参数是什么,并可多步串联(先 list 再 read)。
路由与回复职责分离------路由器只负责「拿数据」,对话模型只负责「讲清楚」。
2. 总体架构
css
用户消息
│
▼
┌─────────────────────────────────────┐
│ Phase A: Agent 路由循环 │
│ runAgentLoop() │
│ LLM (非流式, tools API) │
│ ↔ 工具执行 (Tauri → Rust 沙箱) │
│ 最多 5 步, 产出 observation │
└─────────────────────────────────────┘
│
▼ observation(Markdown 格式的工具执行记录)
┌─────────────────────────────────────┐
│ Phase B: 对话补全 │
│ streamChatCompletion() │
│ LLM (流式, 无 tools) │
│ system 注入 observation + 人设 │
└─────────────────────────────────────┘
│
▼
用户可见的流式回复
关键设计:两次 LLM 调用,两种角色
| 阶段 | API | 流式 | 携带 tools | 职责 |
|---|---|---|---|---|
| Agent 路由 | chatCompletionWithTools |
否 | 是 | 规划并执行本地工具 |
| 用户回复 | streamChatCompletion |
是 | 否 | 解读 observation,自然语言作答 |
每条用户消息始终先走 Phase A :由路由器判断是否需要访问本地工作区;若有工具执行,UI 展示 Agent 状态条(规划 / 执行 / 整理),Phase B 再携带 ISSHIN_AGENT_PERSONA 与 observation 生成流式回复。
3. 核心模块
bash
src/agent/
├── tools.ts # 工具 schema、路由器 system prompt、步数/超时常量
├── graph.ts # ReAct 主循环 runAgentLoop()
├── executor.ts # 工具执行、参数清洗、结果格式化
├── schema.ts # AgentGraphState / AgentStep 类型
└── prompt.ts # 面向用户的 Isshin 人设(Phase B 使用)
src/services/chat.ts # chatCompletionWithTools / streamChatCompletion
src/hooks/useAppState.ts # 串联 Agent → 流式回复
src-tauri/src/workspace.rs # 路径沙箱、排除目录、读/搜限制
3.1 路由器 (tools.ts)
定义 4 个 OpenAI 兼容的 function tools:
| 工具 | 用途 | 典型场景 |
|---|---|---|
list_work_dir |
列目录 | 「work 里有哪些项目」 |
read_work_file |
读单文件 | 「看看 package.json」 |
search_work_text |
全文搜索 | 「哪里用到了 useState」 |
analyze_project |
项目内搜+读源码(≤5 文件) | 「单词卡片怎么生成的」 |
ROUTER_SYSTEM_PROMPT 明确路由器不是最终回复者,规则包括:路径用相对路径、信息足够时停止调工具、纯闲聊不调工具、禁止编造结果。
3.2 ReAct 循环 (graph.ts)
runAgentLoop() 是 Phase A 的入口,逻辑可概括为:
scss
for step in 0..AGENT_MAX_STEPS (5):
1. 组装 messages = [router system, 用户请求+近期对话, ...历史 tool 消息]
2. 调用 chatCompletionWithTools(tool_choice: "auto")
3. 若无 tool_calls → 结束循环(认为信息已够或无需工具)
4. 对每个 tool_call:
- 白名单校验
- executeWorkspaceTool()
- 将 tool 结果以 role: "tool" 写回 reactMessages
5. 进入下一步,LLM 根据 tool 结果决定继续调工具或停止
循环结束后,将所有步骤格式化为 observation(Markdown),供 Phase B 注入。
reactMessages 遵循 OpenAI 多轮 tool calling 协议:
scss
assistant (含 tool_calls) → tool (含 tool_call_id + JSON 结果) → assistant → ...
3.3 工具执行层 (executor.ts)
前端 executor 是 Rust 沙箱的薄封装,额外承担:
- 参数清洗 :剥离
..、.路径段;搜索词限 200 字符 - 项目名模糊匹配 :Levenshtein 距离 ≤4 时自动纠正拼写(
isshin-AI-TextField→ 真实目录名) - 目录误读兜底 :
read_work_file目标是项目文件夹时,自动读 README / package.json 或列目录 analyze_project编排:项目内搜索 → 取最多 5 个相关文件 → 单文件内容超 8KB 截断
执行结果以 JSON 返回给路由器;同时 formatToolResultMarkdown() 生成人类可读的 observation 片段。
3.4 沙箱 (workspace.rs)
Rust 层是最终安全边界:
- 根目录固定:
/Users/miles_wang/Desktop/work canonicalize+starts_with防止路径穿越- 自动跳过
node_modules、.git、target、dist等 - 单文件读取上限 512KB;搜索扫描单文件上限 256KB
Agent 只有读权限,无写、无执行 shell。
4. 端到端数据流
以用户提问「Isshin-Etymonix-AI 里单词卡片怎么生成的」为例:
markdown
1. useAppState.sendMessage()
recentMessages ← 最近 6 条 user/assistant 消息(供指代消解)
2. runAgentLoop()
Step 1: LLM → analyze_project(projectName, query)
Step 2: (若需要) LLM → read_work_file 补充细节
Step 3: LLM → tool_calls 为空,结束
3. observation 示例结构:
## 步骤 1:`analyze_project`
### analyze_project --- `Isshin-Etymonix-AI`
#### `src/components/Card.tsx`
```tsx
...
4. streamChatCompletion()
system: ISSHIN_AGENT_PERSONA + observation
user: 原始问题 + observation(双通道注入,防模型忽略 system)
→ 流式输出解读后的回答
Phase B 的 system prompt 明确要求:工具已由应用执行完毕,禁止再输出 [TOOL_CALL] 或让用户手动跑命令 。sanitizeAssistantContent 在展示层做最后一道过滤。
5. 护栏机制
| 护栏 | 实现 | 触发后果 |
|---|---|---|
| 工具白名单 | ALLOWED_TOOL_NAMES |
返回 { ok: false, error },不执行 |
| 路径清洗 | sanitizeRelativePath |
剥离 ..,防目录穿越 |
| 步数上限 | AGENT_MAX_STEPS = 5 |
记录错误,用已有 observation 继续 |
| 单步超时 | AGENT_STEP_TIMEOUT_MS = 45s |
abort 当前 LLM 请求 |
| 总循环超时 | AGENT_LOOP_TIMEOUT_MS = 120s |
终止整个 Agent 循环 |
| 路由失败降级 | graph.ts catch |
第一步失败 → routedBy: "none",跳过工具,直接进入 Phase B |
| 假工具调用过滤 | stripFakeToolCalls |
UI 层剔除模型幻觉的 [TOOL_CALL] 文本 |
6. Agent 交互与 Prompt 策略
UI 反馈
useAppState 在 Phase A 期间插入 agent-status 消息,随 onPhase 回调更新:
| phase | 用户可见文案 |
|---|---|
thought |
意图识别 / 第 N 步规划工具 |
action |
正在执行本地工具:list_work_dir 等 |
observation |
整理观察结果 |
done |
路由器摘要(如「已完成 2 步工具调用:list → read」) |
若未触发任何工具(shouldAct === false),状态条会被移除,直接进入 Phase B 流式回复。
Phase B Prompt 组成
makefile
system:
ISSHIN_AGENT_PERSONA # 人设、工作区范围、禁止假工具调用
+ observation(若有) # 要求基于代码梳理调用链与实现逻辑
user:
原始问题
+ observation(双通道注入) # 防止模型忽略 system 中的工具结果
分析类问题(analyze_project)的 observation 可能含多个源码文件;system 明确要求解读实现逻辑,而非复述 README。
7. 状态模型
typescript
interface AgentGraphState {
thought: string | null; // 路由器结束时的摘要
shouldAct: boolean; // 是否实际执行过工具
steps: AgentStep[]; // 每步工具名、参数、结果
observation: string | null; // 注入 Phase B 的 Markdown
routedBy: "llm" | "none"; // 是否走路由
errorMessage: string | null; // 超时、步数耗尽等
}
AgentStep 同时保存 resultMarkdown(展示用)和原始 JSON(通过 reactMessages 传给下一轮路由器)。
8. 与旧架构的对比
scss
旧:用户消息 → 关键词 thoughtNode → 硬编码 action → observation → 流式回复
新:用户消息 → LLM 选工具 → executor → (循环) → observation → 流式回复
删除的是 nodes.ts 中约 800 行的意图枚举;新增的是 tools.ts + executor.ts + graph.ts 的分层,以及 chatCompletionWithTools API 封装。
权衡:
- ✅ 意图理解质量显著提升,多轮追问可用
- ✅ 新工具只需加 schema + executor case,无需改路由规则
- ⚠️ 依赖模型对 OpenAI
toolsAPI 的支持质量(部分模型会幻觉 tool call 文本,靠 prompt + 后处理缓解) - ⚠️ 每次用户消息至少 1 次非流式 LLM 调用(路由),有工具时可能 2--5 次,整体延迟高于无工具的闲聊
9. 扩展指南
新增一个工作区能力(例如「读 git log」)的标准步骤:
workspace.rs--- 实现 Tauri command + 沙箱校验tools.ts--- 添加WORKSPACE_TOOLS条目 +ALLOWED_TOOL_NAMESexecutor.ts---executeWorkspaceTool增加 case +formatToolResultMarkdown格式化ROUTER_SYSTEM_PROMPT--- 补充 1--2 句使用场景说明
无需修改 graph.ts 循环逻辑------这是分层带来的主要收益。
10. 调试
- LLM 控制台 (独立窗口):可查看每次
Agent 路由 (tools)请求的完整 request/response,包括tool_calls原始 JSON - Agent 状态条 :实时显示当前 phase(规划 / 执行
list_work_dir/ 整理观察) - observation 内容:最终注入 system 的 Markdown 即 Agent 实际读到的数据,是排查「模型胡说」的第一现场
附录:关键常量
typescript
WORKSPACE_ROOT = "/Users/miles_wang/Desktop/work"
AGENT_MAX_STEPS = 5
AGENT_LOOP_TIMEOUT_MS = 120_000 // 2 分钟
AGENT_STEP_TIMEOUT_MS = 45_000 // 45 秒
MAX_ANALYZE_FILES = 5 // analyze_project 最多读取文件数
MAX_FILE_CONTENT_IN_OBS = 8_000 // 单文件注入 observation 的字符上限