03. 没有 Run 对象,你的 Agent 只是个高级 Chatbot 套壳
系列来源:本文属于 Agent 开发系列,内容来自 gusibi/molibot 的真实开发记录与项目文档整理。这是系列第三篇,前两篇分别讲了 Agent 的四层分类和 runtime 的整体架构——没看过不影响理解本篇。

你有没有过这种经历——
线上 Agent 出问题了,你翻日志翻了半小时,还没搞清楚它当时到底在执行哪个任务。用户问"刚才那个请求还在跑吗",你只能回答"大概吧"。服务一重启,内存里那些 running 状态全丢了,外面还以为任务卡住了。
如果你有过,那这篇就是写给你的。
上一篇我们区分了 Chatbot、Tool-using Chatbot、Agent Service 和 Agent System。结论很简单:差别不在模型,而在 runtime。
但 runtime 这个词还是太大了。真正落到工程里,第一个问题应该更小:
一条用户消息进来以后,系统到底怎么把这一轮跑完?
先别急着做多 Agent。也别急着接 Telegram、飞书、微信。更不要一开始就设计一堆工具市场、插件系统、长期记忆。
如果一轮 run 都跑不清楚,后面所有功能都会挂在一团不稳定的东西上。
没有 Run 对象的 Agent 系统,本质上还是个 Chatbot 套壳。
这一篇只讲一件事:一个最小 Agent 服务,如何把一轮 run 跑清楚。
为什么先讲 run?
很多 Agent demo 没有 run 的概念。
用户发来一句话,程序把历史消息拼一下,调用模型。模型要工具,就执行工具。最后把回答发回去。
看起来也能跑。
但一旦出问题,你很快会发现系统没有抓手。工具失败了,你只看到一条错误日志,不知道属于哪轮对话。用户发"停一下",你不知道要停哪一次执行。最终回答发出去了,中间工具结果没保存,后面没法复盘。
这些问题的共同根因是:系统没有把"这一次执行"当成一个对象。
run 就是这个对象。
- session:一段连续对话
- run:一次用户输入触发的执行过程
一个 session 里可以有很多 run。比如用户上午问了一次"帮我生成图片",下午又问"把那张图做成视频"——这是同一个 session,但一定是两个 run。
把这个边界分清楚,后面很多问题都会变简单。

一轮 run 从哪里开始?
一轮 run 的起点不是模型调用。
它从用户输入进入系统开始。
假设用户发来一句:
帮我看一下这个目录里有没有过期的配置文件。
如果有,列出来并解释原因。
最小 Agent 服务收到这句话后,第一步不是立刻调模型,而是先确定上下文:
inbound message
-> normalize # 不同入口的消息统一结构
-> find session # 找到或创建会话
-> start run # 标记一次执行开始
-> build context # 准备模型能看到的内容
每一步都有意义。
normalize 是把 Web 表单、CLI 输入、Telegram 消息这些不同外形的输入,进了 runtime 以后尽量长成一个样子。
find session 是为了知道这句话接在哪段对话后面。继续老任务就带历史,开新任务就开新上下文。
start run 是给这次执行一个身份证。后面的模型调用、工具调用、失败、停止、最终回答,都要挂到这个 run 上。
build context 才是准备模型输入。它决定模型能看到什么:系统规则、用户消息、最近历史、工具结果、相关记忆,以及这轮 run 的临时信息。
所以最小流程不是 user -> model,而是:
user -> session -> run -> context -> model

多了几步,但每一步都在给后续恢复和排障留位置。
这一路需要哪些对象?一个最小 Agent 服务不需要复杂的对象模型,但至少要有五个。
| 对象 | 回答的问题 | 关键字段 |
|---|---|---|
| Message | 模型看到了什么? | role, content, toolCallId? |
| Session | 这句话接在哪段对话后面? | id, messages[], activeRunId? |
| Run | 当前这次任务跑到哪了? | id, sessionId, status, startedAt, finishedAt? |
| ToolCall | 模型想调用什么? | id, runId, name, arguments |
| ToolResult | 工具执行得怎么样? | toolCallId, status, content, error? |
用一个嵌套结构表示更直观:
Session
messages[]
activeRunId?
Run
id
sessionId
status
startedAt
finishedAt?
stopReason?
ToolCall
id
runId
name
arguments
ToolResult
toolCallId
status
content
error?
这不是数据库设计,只是对象边界。
对象边界清楚,存哪里都好说。可以先用文件,也可以用 SQLite。重要的是不要把它们混成一坨聊天历史。

模型调用不是终点,而是循环的一步
很多人会把模型调用当成核心。
确实,模型调用很重要。
但在 Agent 服务里,它只是循环的一步。
模型可能返回两类结果——
第一类是普通回答,比如:
这个目录里没有明显过期的配置文件。
这时 run 可以进入提交阶段。
第二类是工具调用,比如:
tool_call:
name: listFiles
arguments:
path: "./config"
这时 run 不能结束。runtime 要执行工具,把结果回灌给模型,再进入下一轮模型调用。
所以最小 Agent loop 更像这样:
while run is active:
response = model(messages, tools)
if response is final answer:
commit answer
finish run
break
if response has tool calls:
execute tools
append tool results to messages
continue
这段逻辑不复杂。
复杂的是边界——工具执行失败怎么办?模型返回空怎么办?用户中途 stop 怎么办?上下文太长怎么办?这些都必须进入 run 的状态。否则循环看起来能跑,实际一碰异常就散。
而这里面最容易被低估的一点,是工具结果必须回灌给模型。
很多系统执行工具后,会把结果直接展示给用户,然后让模型写一句总结。问题是,模型没有看到工具结果。
比如模型调用了 listFiles,runtime 得到结果:
config/
app.old.json
app.json
sandbox.local.json
如果这个结果只显示在 UI,不进入模型上下文,模型下一步就不知道目录里有什么。它可能会编一个总结。
更糟的是工具失败时。假设工具返回:
permission denied: config/
如果模型看不到这个失败,它可能仍然回答"我已经检查过了"。这就是很典型的 Agent 幻觉——不是模型凭空坏,而是系统没有把真实观察给它。
所以工具结果要作为 tool result 回到 messages:
assistant:
tool_call listFiles({ path: "./config" })
tool:
tool_call_id: "call_123"
content: "permission denied: config/"
模型看到这个结果后,才有机会继续判断:
- 是否换一个路径?
- 是否请求审批?
- 是否告诉用户权限不足?
- 是否停止当前任务?
Agent 的智能不是模型单独产生的,而是模型和 runtime 通过"观察结果"来回迭代产生的。

三个最容易踩的坑
坑 1:状态只存在内存里
最小版本可以从很少的状态开始:
queued / running / waiting_approval / completed / failed / stopped
但这些状态不要只存在内存里。
只存在内存里的状态,服务重启后就消失。用户以为任务还在跑,系统却已经忘了。或者反过来,系统内存以为没有任务,但外部工具还在执行。
最小持久化可以很朴素:
RunStore.start(run)
RunStore.markRunning(run.id)
RunStore.recordToolCall(run.id, toolCall)
RunStore.recordToolResult(run.id, result)
RunStore.finish(run.id, status)
哪怕后面再换数据库或结构,这个事实流也应该保留——它是系统恢复、审计和排障的基础。
坑 2:把 streaming 当成最终答案
很多聊天产品都有 streaming。模型一边生成,前端一边显示。用户体验很好。
但在 Agent 服务里,streaming 不能等同于最终回答。
用户看到屏幕上出现了一段字,只能说明系统正在生成草稿。它还没有完成提交。
为什么要区分?因为 run 可能在 streaming 过程中失败——模型生成到一半,发现还需要调用工具。或者用户按了 stop。或者平台消息编辑失败。或者服务重启。
如果系统把 streaming 中的半截内容当成最终答案保存,后面 session 就会被污染。
更好的做法是区分三类输出:
| 类型 | 给谁看 | 进不进模型上下文 |
|---|---|---|
| progress | 用户 | 不一定 |
| draft(streaming 中) | 用户 | 失败时不提交 |
| committed answer | 用户 + 模型 | 正式历史 |
这个边界看起来细,但它会直接影响停止、重试和恢复。
坑 3:只写 happy path
最小闭环不是只写 happy path。
Agent 服务的失败路径很多,而且都是正常情况。模型可能返回空响应。工具可能参数错误。工具可能被权限策略拒绝。外部 provider 可能超时。用户可能中途 stop。上下文可能超过模型限制。平台可能发送失败。
如果你只在最外层 catch 一个异常,然后告诉用户"失败了",系统就失去了继续能力。
更好的方式是把失败分类:
model_empty_response → 回灌给模型,让它重试
tool_validation_error → 回灌给模型,修正参数
tool_policy_denied → 回灌给模型,请求审批
tool_execution_failed → 回灌给模型,换个方案
user_aborted → 结束 run,不回灌
context_too_large → runtime 处理,压缩上下文
delivery_failed → 用户层面提示,不影响 run 状态
给模型看的,是它能用来继续推理的部分。给用户看的,是能帮助他理解当前状态的说明。给开发者看的,是结构化错误码和 runId。
三者不要混在一起。

一个完整的最小 run 长什么样?
把前面的内容串起来,可以得到一个更完整的伪代码:
function handleUserMessage(inbound):
message = normalize(inbound)
session = SessionStore.findOrCreate(message.conversationId)
run = RunStore.start(session.id, message)
try:
messages = ContextBuilder.build(session, message)
while RunStore.isActive(run.id):
response = Model.call(messages, ToolRegistry.available())
if response.isEmpty:
RunStore.fail(run.id, "model_empty_response")
return userMessage("模型没有返回有效内容")
if response.hasToolCalls:
for toolCall in response.toolCalls:
RunStore.recordToolCall(run.id, toolCall)
result = ToolRuntime.execute(toolCall)
RunStore.recordToolResult(run.id, result)
messages.append(asToolMessage(toolCall.id, result))
continue
answer = response.text
SessionStore.commitAnswer(session.id, answer)
RunStore.finish(run.id, "completed")
return userMessage(answer)
catch AbortError:
RunStore.finish(run.id, "stopped")
return userMessage("已停止")
catch error:
RunStore.fail(run.id, classify(error))
return userMessage("这次任务失败了,已记录诊断信息")
这段伪代码仍然很简化。真实系统还会有 streaming、审批、队列、trace、上下文压缩和多渠道发送。
但它已经包含最重要的骨架:
- 每轮用户输入都有 session
- 每次执行都有 run
- 模型调用在 loop 里
- 工具结果回灌给模型
- 状态和失败被记录
- 最终答案才提交到 session
只要这个骨架稳定,后面扩展就有位置。
最小 Agent 服务的及格线
“最小 Agent 服务"很容易被误解成"少写一点代码”。
其实不是。最小的意思是:只保留必要对象和必要流程。不是没有状态。不是没有错误处理。不是把所有历史都塞给模型。不是工具失败就直接丢给用户。
一个好的最小版本,应该能回答下面这 7 个问题:
| # | 问题 | 答不上来意味着什么 |
|---|---|---|
| 1 | 这轮 run 的 id 是什么? | 出了问题找不到根 |
| 2 | 它属于哪个 session? | 上下文串不起来 |
| 3 | 当前状态是什么? | 用户问"还在跑吗"你答不上 |
| 4 | 调用了哪些工具? | 不知道时间花在哪了 |
| 5 | 工具结果回灌了吗? | 模型可能在幻觉 |
| 6 | 最终答案提交了吗? | session 历史可能被半截内容污染 |
| 7 | 如果失败,失败类型是什么? | 没法分类处理,只能笼统说"失败了" |
如果这些问题答得上来,就算功能很少,它也是一个 Agent Service 的起点。 如果这些问题答不上来,就算工具很多,它也只是一个更复杂的 demo。
回到开头那个问题
还记得开头那个场景吗——线上出问题了,翻日志翻了半小时,用户问「刚才那个请求还在跑吗」,你只能回答「大概吧」。
如果你现在能回答「run_20260727_001,状态是 waiting_approval,卡在权限检查,需要你确认」,你就不是在写 demo 了。
你在做服务。
本文中讨论的所有概念——Session、Run、ToolCall、ToolResult——在 gusibi/molibot 中都有对应的实现。如果你不想从零搭,可以直接看源码。下一篇我们拆 Agent runtime 的核心对象模型:哪些东西该进模型上下文,哪些只该进 trace。