返回博客列表

Agent 不是一个 while 循环,而是一套本地运行时

2026-07-04
4 min read
aiagentAI工程

本文以 jvs-cli 的 agent 核心逻辑为例,拆解一个本地 Agent Runtime 应该如何处理提示词、会话状态、工具调用、审批和沙箱边界。

核心判断:Agent 不是“调一次大模型”这么简单。真正进入工程实现后,它更像一个本地运行时:要管状态、管工具、管审批、管副作用,还要保证每一步都能被追踪。

一、Agent 到底难在哪里

很多人刚开始写 Agent,第一反应是这样的:

ts
while (true) {
  const response = await llm(messages);
  if (response.toolCalls.length === 0) break;
  await runTools(response.toolCalls);
}

这个思路没错,但只能算“玩具版 Agent”。

因为它漏掉了几个生产级问题:

  • 系统提示词从哪里来,怎么组合,怎么调试?
  • 工具能不能直接执行?谁来判断风险?
  • 用户审批时,当前会话状态怎么暂停和恢复?
  • 工具执行失败后,模型怎么继续规划?
  • 多个 session 同时跑时,turn 状态会不会串?
  • 本地文件和 shell 命令有副作用,怎么限制边界?

说白了,Agent 的核心不是模型调用,而是围绕模型调用构建一套可控的运行时。

jvs-cli 里,我们把核心逻辑拆成了几层:

mermaid
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 内部怎么跑。

它的大致流程是:

  1. 读取当前 session snapshot。
  2. 如果这是新 turn,生成 turnId。
  3. 给最新用户消息绑定 turnId。
  4. 构造模型请求。
  5. 调用模型。
  6. 保存 assistant message。
  7. 如果没有工具调用,结束 turn。
  8. 如果有工具调用,逐个处理工具。
  9. 工具结果写回 messages 后,再继续下一轮模型调用。

这里最容易踩坑的是 turn 状态。

一开始很多人会把这些状态放在 AgentLoop 实例字段里:

ts
private currentTurnId: string | null;
private steps: TurnStep[];
private turnStartTime: number | null;

但这样有个问题:同一个 AgentLoop 如果同时推进多个 session,状态就会串。

所以现在的设计是:

ts
type ActiveTurnState = {
  turnStartTime: number;
  currentTurnId: string;
  steps: TurnStep[];
};

private readonly activeTurns = new Map<string, ActiveTurnState>();

也就是说,turn 状态按 sessionId 隔离。

这是一个很典型的工程教训:

只要运行时允许并发,状态就不能只按实例保存,而要按业务 key 隔离。

四、PromptComposer:系统提示词不是一段字符串

早期实现里,systemPrompt 只是一个字符串,直接塞进模型请求。

这样能跑,但不好维护。

因为系统提示词其实有很多来源:

  • 助手基础身份
  • 当前 workspace 信息
  • 可用工具列表
  • 当前 session 状态
  • 当前任务规则
  • 用户自定义规则

如果都揉成一段字符串,后面会很难调试:你不知道某条规则是谁加进去的,也不知道为什么模型突然变了。

所以我们加了 PromptComposer

它把提示词拆成多个 PromptPart

ts
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 才能读文件、跑命令、操作本地项目。

但本地工具一旦有副作用,就不能随便执行。

所以我们拆成两层:

text
ToolPolicy
负责判断:allow / ask-user / deny

ToolSandbox
负责执行约束:路径、命令、结果大小

ToolPolicy 是决策层。

比如:

  • workspace 内的 read_file:默认允许。
  • workspace 外的 read_file:需要用户审批。
  • shell 命令:需要用户审批。
  • 有副作用工具:默认需要用户审批。
  • 明确危险操作:拒绝。

ToolSandbox 是执行层。

比如:

  • 规范化路径。
  • 限制默认读取范围。
  • shell 禁止命令串联、管道、重定向、命令替换、sudorm
  • 工具结果过大时截断。
  • 审批通过后,允许读取用户明确指定的 workspace 外文件。

注意这里有个很重要的修正。

一开始我们把 read_file 限死在 workspace 里。这个设计不对。

因为用户可能明确说:“读取 /Users/me/Desktop/a.txt”。

这时正确逻辑不是硬拒绝,而是:

text
workspace 内文件 -> 自动允许
workspace 外文件 -> 需要审批
审批通过后 -> 允许读取

所以现在执行接口加了一个标记:

ts
execute(toolCall, { approved: true })

AgentRuntime.approve_request 里会带上这个标记。这样 ToolSandbox 就知道:这次外部路径读取是用户批准过的,不是模型绕过边界偷偷读。

这个设计背后的本质是:

沙箱不是把所有东西都锁死,而是把默认行为、用户授权和危险操作分清楚。

六、审批流:暂停不是失败

当模型返回一个需要审批的工具调用时,系统不会把它当成失败。

它会把 session 切到:

ts
status: 'waiting_for_approval'

同时保存:

  • pendingApproval
  • pendingToolCall
  • pendingToolQueue

这几个字段很关键。

因为模型一次可能返回多个工具调用:

text
tool A
tool B
tool C

如果 tool B 需要审批,那系统必须记住:

  • 当前卡在 tool B
  • tool C 还没执行
  • 用户批准后要继续 tool C
  • 用户拒绝后要把拒绝写回上下文,再让模型重新规划

这就是为什么审批不能只是一个弹窗。

审批是运行时状态机的一部分。

七、事件流:UI 不应该直接读写运行时

EventBus 负责把核心状态变化发出去。

常见事件包括:

  • snapshot_updated
  • turn_started
  • assistant_text_delta
  • tool_call
  • tool_call_end
  • approval_requested
  • turn_finished
  • session_completed

这样 CLI、App、Server 都不需要直接改 session。

它们只要订阅事件,就能知道当前发生了什么。

这也是一个很重要的架构边界:

Agent 核心逻辑负责推进状态,UI 负责展示状态。两者不要互相污染。

八、为什么暂时不做记忆系统

记忆系统听起来很诱人。

但学习阶段我们暂时不做。

原因很简单:记忆不是只追加几行文本那么简单。它要处理:

  • 新增
  • 更新
  • 覆盖
  • 废弃
  • 冲突
  • 隐私
  • 注入策略
  • 用户确认

如果现在就做,范围会一下子变大。

所以当前选择是:

  • 长期规则写进设计文档。
  • 项目规则通过 PromptComposertask/user 层显式注入。
  • 不做自动记忆,避免污染上下文。

这是一个取舍:

先把运行时主链路打稳,再谈记忆这种高级能力。

九、这套核心逻辑的本质

回头看,jvs-cli 的 Agent 核心不是一个神秘系统。

它就是几层朴素的工程对象:

模块本质职责
AgentRuntime会话级调度器
AgentLoop单轮推进器
PromptComposer系统提示词组装器
ToolPolicy工具风险决策器
ToolSandbox工具执行约束器
SessionStore会话状态持久化
EventBus状态变化广播
TurnBuffer流式输出缓冲

真正有价值的不是“用了哪个模型”,而是这些对象之间的边界。

因为模型会换,工具会变,UI 也会重做。

但只要运行时边界稳,系统就能继续演进。

十、最后总结

Agent 工程里最容易犯的错,是把模型当成系统。

但模型只是系统里的一个执行节点。

真正的 Agent Runtime 要解决的是:

  • 如何构造上下文
  • 如何推进会话状态
  • 如何处理工具调用
  • 如何暂停和恢复
  • 如何审批副作用
  • 如何隔离本地风险
  • 如何让 UI 观察整个过程

所以本文的核心结论是:

Agent 不是一个 while 循环,而是一套围绕模型调用建立起来的本地运行时。

写 Agent,不是让模型“更聪明”这么简单。

更重要的是,让模型的每一步,都跑在一个能解释、能追踪、能审批、能兜底的工程系统里。

返回博客列表
最后更新于 2026-07-04
想法或问题?在 GitHub Issue 下方参与讨论
去评论