核心判断:Agent 不是“调一次大模型”这么简单。真正进入工程实现后,它更像一个本地运行时:要管状态、管工具、管审批、管副作用,还要保证每一步都能被追踪。
一、Agent 到底难在哪里
很多人刚开始写 Agent,第一反应是这样的:
while (true) {
const response = await llm(messages);
if (response.toolCalls.length === 0) break;
await runTools(response.toolCalls);
}
这个思路没错,但只能算“玩具版 Agent”。
因为它漏掉了几个生产级问题:
- 系统提示词从哪里来,怎么组合,怎么调试?
- 工具能不能直接执行?谁来判断风险?
- 用户审批时,当前会话状态怎么暂停和恢复?
- 工具执行失败后,模型怎么继续规划?
- 多个 session 同时跑时,turn 状态会不会串?
- 本地文件和 shell 命令有副作用,怎么限制边界?
说白了,Agent 的核心不是模型调用,而是围绕模型调用构建一套可控的运行时。
在 jvs-cli 里,我们把核心逻辑拆成了几层:
flowchart TD
User["用户输入"] --> Runtime["AgentRuntime<br/>会话级调度器"]
Runtime --> Store["SessionStore<br/>保存 snapshot / messages / telemetry"]
Runtime --> Loop["AgentLoop<br/>单轮或多轮推进"]
Loop --> Prompt["PromptComposer<br/>组合 system prompt"]
Prompt --> ModelReq["ModelRequest<br/>systemPrompt + messages + tools"]
ModelReq --> LLM["ModelProvider<br/>DeepSeek / Kimi / 其他模型"]
LLM --> ModelResp["ModelResponse<br/>text + toolCalls + usage"]
ModelResp --> Loop
Loop -->|无 tool call| Finish["turn_finished<br/>session_completed"]
Loop -->|有 tool call| Policy["ToolPolicy<br/>allow / ask-user / deny"]
Policy -->|allow| Sandbox["ToolSandbox<br/>路径/命令/结果约束"]
Policy -->|ask-user| Approval["pendingApproval<br/>等待用户审批"]
Policy -->|deny| ToolError["tool-error<br/>写回上下文"]
Approval -->|approve| Sandbox
Approval -->|reject| ToolError
Sandbox --> ToolResult["tool-call result<br/>写回 messages"]
ToolResult --> Loop
Loop --> Bus["EventBus<br/>snapshot_updated / tool_call / approval_requested"]
Runtime --> Bus
这张图可以看出一件事:模型只是其中一环。真正让 Agent 稳定运行的,是外面这一圈状态机和安全边界。
二、AgentRuntime:会话级调度器
AgentRuntime 的职责不是“跑模型”,而是管理整个 session 生命周期。
它处理几类命令:
start_session:创建新会话,写入第一条用户消息,然后启动 AgentLoop。append_user_message:追加用户输入,必要时排队。approve_request:用户批准工具执行后,恢复被挂起的工具调用。reject_request:用户拒绝工具执行后,把拒绝结果作为 tool-error 写回上下文。load_session:重新发布当前 snapshot,供 UI 恢复展示。
它有点像控制面。
为什么不让 UI 直接调用 AgentLoop?
因为 UI 不应该知道“当前 session 是否在审批中”“是否有 pending tool queue”“是否需要排队用户输入”。这些都是运行时状态,应该由 AgentRuntime 统一处理。
一句话:
AgentRuntime 管的是会话秩序,不是模型细节。
三、AgentLoop:单轮推进器
AgentLoop 负责的是一轮 turn 内部怎么跑。
它的大致流程是:
- 读取当前 session snapshot。
- 如果这是新 turn,生成 turnId。
- 给最新用户消息绑定 turnId。
- 构造模型请求。
- 调用模型。
- 保存 assistant message。
- 如果没有工具调用,结束 turn。
- 如果有工具调用,逐个处理工具。
- 工具结果写回 messages 后,再继续下一轮模型调用。
这里最容易踩坑的是 turn 状态。
一开始很多人会把这些状态放在 AgentLoop 实例字段里:
private currentTurnId: string | null;
private steps: TurnStep[];
private turnStartTime: number | null;
但这样有个问题:同一个 AgentLoop 如果同时推进多个 session,状态就会串。
所以现在的设计是:
type ActiveTurnState = {
turnStartTime: number;
currentTurnId: string;
steps: TurnStep[];
};
private readonly activeTurns = new Map<string, ActiveTurnState>();
也就是说,turn 状态按 sessionId 隔离。
这是一个很典型的工程教训:
只要运行时允许并发,状态就不能只按实例保存,而要按业务 key 隔离。
四、PromptComposer:系统提示词不是一段字符串
早期实现里,systemPrompt 只是一个字符串,直接塞进模型请求。
这样能跑,但不好维护。
因为系统提示词其实有很多来源:
- 助手基础身份
- 当前 workspace 信息
- 可用工具列表
- 当前 session 状态
- 当前任务规则
- 用户自定义规则
如果都揉成一段字符串,后面会很难调试:你不知道某条规则是谁加进去的,也不知道为什么模型突然变了。
所以我们加了 PromptComposer。
它把提示词拆成多个 PromptPart:
type PromptPart = {
id: string;
source: 'base' | 'workspace' | 'tools' | 'session' | 'task' | 'user';
priority: number;
content: string;
};
然后按 priority 排序,组合成最终的 systemPrompt。
分层之后,边界就清楚了:
| 分层 | 职责 |
|---|---|
base | 通用本地助手身份、真实性约束、回答风格 |
workspace | 当前工作区、运行模式、模型信息 |
tools | 可用工具和工具使用规则 |
session | 当前会话状态,例如是否等待审批 |
task | 当前任务或项目级指导 |
user | 用户自定义系统提示词 |
这里有一个关键判断:
PromptComposer 不是为了“把提示词写得更长”,而是为了让提示词来源可解释、可测试、可演进。
五、工具调用:审批不是沙箱
工具调用是 Agent 最危险也最有价值的部分。
没有工具,Agent 只能聊天。 有了工具,Agent 才能读文件、跑命令、操作本地项目。
但本地工具一旦有副作用,就不能随便执行。
所以我们拆成两层:
ToolPolicy
负责判断:allow / ask-user / deny
ToolSandbox
负责执行约束:路径、命令、结果大小
ToolPolicy 是决策层。
比如:
- workspace 内的
read_file:默认允许。 - workspace 外的
read_file:需要用户审批。 - shell 命令:需要用户审批。
- 有副作用工具:默认需要用户审批。
- 明确危险操作:拒绝。
ToolSandbox 是执行层。
比如:
- 规范化路径。
- 限制默认读取范围。
- shell 禁止命令串联、管道、重定向、命令替换、
sudo、rm。 - 工具结果过大时截断。
- 审批通过后,允许读取用户明确指定的 workspace 外文件。
注意这里有个很重要的修正。
一开始我们把 read_file 限死在 workspace 里。这个设计不对。
因为用户可能明确说:“读取 /Users/me/Desktop/a.txt”。
这时正确逻辑不是硬拒绝,而是:
workspace 内文件 -> 自动允许
workspace 外文件 -> 需要审批
审批通过后 -> 允许读取
所以现在执行接口加了一个标记:
execute(toolCall, { approved: true })
AgentRuntime.approve_request 里会带上这个标记。这样 ToolSandbox 就知道:这次外部路径读取是用户批准过的,不是模型绕过边界偷偷读。
这个设计背后的本质是:
沙箱不是把所有东西都锁死,而是把默认行为、用户授权和危险操作分清楚。
六、审批流:暂停不是失败
当模型返回一个需要审批的工具调用时,系统不会把它当成失败。
它会把 session 切到:
status: 'waiting_for_approval'
同时保存:
pendingApprovalpendingToolCallpendingToolQueue
这几个字段很关键。
因为模型一次可能返回多个工具调用:
tool A
tool B
tool C
如果 tool B 需要审批,那系统必须记住:
- 当前卡在 tool B
- tool C 还没执行
- 用户批准后要继续 tool C
- 用户拒绝后要把拒绝写回上下文,再让模型重新规划
这就是为什么审批不能只是一个弹窗。
审批是运行时状态机的一部分。
七、事件流:UI 不应该直接读写运行时
EventBus 负责把核心状态变化发出去。
常见事件包括:
snapshot_updatedturn_startedassistant_text_deltatool_calltool_call_endapproval_requestedturn_finishedsession_completed
这样 CLI、App、Server 都不需要直接改 session。
它们只要订阅事件,就能知道当前发生了什么。
这也是一个很重要的架构边界:
Agent 核心逻辑负责推进状态,UI 负责展示状态。两者不要互相污染。
八、为什么暂时不做记忆系统
记忆系统听起来很诱人。
但学习阶段我们暂时不做。
原因很简单:记忆不是只追加几行文本那么简单。它要处理:
- 新增
- 更新
- 覆盖
- 废弃
- 冲突
- 隐私
- 注入策略
- 用户确认
如果现在就做,范围会一下子变大。
所以当前选择是:
- 长期规则写进设计文档。
- 项目规则通过
PromptComposer的task/user层显式注入。 - 不做自动记忆,避免污染上下文。
这是一个取舍:
先把运行时主链路打稳,再谈记忆这种高级能力。
九、这套核心逻辑的本质
回头看,jvs-cli 的 Agent 核心不是一个神秘系统。
它就是几层朴素的工程对象:
| 模块 | 本质职责 |
|---|---|
AgentRuntime | 会话级调度器 |
AgentLoop | 单轮推进器 |
PromptComposer | 系统提示词组装器 |
ToolPolicy | 工具风险决策器 |
ToolSandbox | 工具执行约束器 |
SessionStore | 会话状态持久化 |
EventBus | 状态变化广播 |
TurnBuffer | 流式输出缓冲 |
真正有价值的不是“用了哪个模型”,而是这些对象之间的边界。
因为模型会换,工具会变,UI 也会重做。
但只要运行时边界稳,系统就能继续演进。
十、最后总结
Agent 工程里最容易犯的错,是把模型当成系统。
但模型只是系统里的一个执行节点。
真正的 Agent Runtime 要解决的是:
- 如何构造上下文
- 如何推进会话状态
- 如何处理工具调用
- 如何暂停和恢复
- 如何审批副作用
- 如何隔离本地风险
- 如何让 UI 观察整个过程
所以本文的核心结论是:
Agent 不是一个 while 循环,而是一套围绕模型调用建立起来的本地运行时。
写 Agent,不是让模型“更聪明”这么简单。
更重要的是,让模型的每一步,都跑在一个能解释、能追踪、能审批、能兜底的工程系统里。