持久化目标
持续执行模式(第一版)
新建目标默认 persistentExecution=true,省略预算时 turnBudget=0、llmCallBudget=0 表示不设累计上限。显式正预算仍生效;已有目标保留旧模式,不会在升级后自动启动。
持续目标由数据库队列和后台 supervisor 跨图片段调度;单次图执行的次数上限只结束当前片段,不结束目标。队列、冷却、重试和过期租约可在服务重启后恢复。仅当清单全部通过且有非空证据时才能完成,Stop、缺少必要输入、审批拒绝和预算耗尽会暂停,需明确 resume。
GET /api/v1/goals/{id}/execution 返回独立的调度状态、原因和到期时间;它是最近的调度记录,目标当前状态以 goal API 为准。流中通过 goal_continuation 广播调度变化。第一版支持单后端实例的原生 runtime,不保证外部工具副作用恰好一次;恢复先检查已有产物与异步句柄。预算在片段边界检查,不是逐请求的硬费用限制。
持久执行契约
持续执行把长目标和任何一次 HTTP 请求或图执行解耦。数据库保存进程退出后仍必须存在的工作状态:
| 持久状态 | 重启后的作用 |
|---|---|
| 目标与验收清单 | 完成标准不会改变 |
| Continuation 记录 | Supervisor 知道任务正在排队、冷却、重试、暂停还是阻塞 |
| Attempt 与 lease | 新片段认领前可以先判断上一次中断的位置与安全性 |
| 已接收输入队列 | 员工忙碌期间收到的消息稍后消费,不会被丢弃 |
| 进度账本与工作区产物 | 下一片段可以根据证据继续,而不是靠模型猜测 |
每个片段会认领目标,最多消费一条排队输入,执行一次有界图运行,保存结果,然后进入终态或排队下一片段。后端重启后,会先释放孤立的输入认领并核对过期 attempt,再恢复普通调度。
恢复策略偏保守:安全调用可以重试;已经保存的 assistant 消息会进入核对;工具完成证据会被重新读取;可能产生了不确定外部副作用的工具则会阻塞并等待复核。这样不会把“进程重启”悄悄变成“再付一次款、再发布一次或再删除一次”。
执行期间接收的新输入
持续目标正在运行时,新用户输入会先写入 mate_conversation_input_queue,再交给后续 attempt。输入项依次处于 queued、claimed、consumed 状态。如果进程在认领后、消费前退出,启动恢复会释放孤立认领,使同一条输入仍可继续处理。
队列保证已经接收的输入不会丢失,但不会自动消除相互冲突的指令。用户仍可暂停或放弃目标;如果新输入改变验收标准,应明确更新目标,而不是依赖一句含义不清的跟进消息。
让任务稳定运行数小时
运行时可以保存调度状态,但任务本身仍需设计成可恢复的检查点。对于长篇写作、代码生成、研究资料汇总或数据导出:
- 批量执行前先建立具体标准。字数、文件路径、必备章节、测试和最终校验,比“产出高质量结果”更可验证。
- 在工作区保存简短计划与进度账本,记录最后完成单元、累计数量、未解决事项以及下一步动作。
- 使用有界写入。写作任务每次追加一章或 2,000–4,000 个汉字,比一次工具调用塞入整卷更易重试和检查。
- 恢复后先重读检查点与目标文件尾部,根据已有证据续写,不依赖被中断回合里的模型记忆。
- 优先使用可安全重试的变更。
append_file发现文件尾部已经存在完全相同的内容时,会返回alreadyApplied=true而不重复写入;expectedTail可在文件已被其他写入改变时拒绝追加。 - 控制模型请求节奏。根据模型配额设置最小 continuation 间隔和 provider 全局退避;重试风暴不等于任务进度。
- 完成前重新验收:重算内容数量、枚举完成单元、读取结尾、重跑测试;所有标准都有非空证据后才能调用
completeGoal。
示例:50 万字小说稳定性测试
一个有代表性的稳定性用例,是让通用助手为十卷、约 180 章的玄幻小说创建持续目标,保存设定圣经、全书大纲和进度账本,然后逐章追加,直至正文不少于 500,000 个中文汉字。每次恢复都必须先检查已有章节标题和文件尾部,再写下一章。
在 2026-08-30 的单实例验证中,后端在生成章节时被停止。恢复检查点已有 12 章、15,342 个中文汉字。重启后,supervisor 恢复原始目标和会话上下文,读取大纲、进度账本和卷尾,从第 13 章继续;观察窗口内写到第 17 章、24,628 个中文汉字,重复章节标题为 0。期间发生的 provider 限流和一次短暂连接错误进入退避后继续运行。这些数字只记录该次测试,不代表吞吐量承诺。
复测时可在重启前后记录以下值:
FILE=data/workspace/novel-ash-sea/volumes/volume-01.md
rg -c '^## 第.*章' "$FILE"
python3 - <<'PY'
import re
from pathlib import Path
text = Path("data/workspace/novel-ash-sea/volumes/volume-01.md").read_text(encoding="utf-8")
print(len(re.findall(r"[\u4e00-\u9fff]", text)))
PY
rg '^## 第.*章' "$FILE" | sort | uniq -d最后一条命令应无输出。同时检查 GET /actuator/health、GET /api/v1/goals/{id}/execution、continuation 日志、进度账本和最新产物时间。只有重启后能写入新的工作单元,并且没有丢失排队输入或重复上一个单元,才算恢复验证通过。
当前边界
Persistent Goal 第一版面向单后端实例和 Native Runtime 设计与验证。数据库状态可以跨该实例重启恢复,但任意外部副作用并非严格一次。付款、发送、发布和破坏性操作应使用服务商幂等键、执行前后核对或人工复核。
以前你每轮都要把上下文重复一遍。现在你定一个目标,员工自己跟。
一次对话里你说"帮我把这个博客部署到 fly.io",员工答完一轮就停了。下一轮你要再问"DNS 配好没?证书呢?测试跑了吗?"——你在替它记目标。
Goal 把这件事翻过来。你说一次,员工锁住目标,自己每轮自检:还差什么?要不要自己再做一步?
它不是聊天里的一个新功能。它是员工的一种状态。员工头像周围多了一圈光,光填多少就是离完成多远。完成了,光消失。
它在视觉上长什么样
不是一个 banner。不是一个 dialog。不是一个独立的标签页。
是 assistant 头像周围的一圈环。
| 状态 | 视觉 | 含义 |
|---|---|---|
| 无目标 | 头像就是头像 | 这条对话没绑目标,跟过去一样 |
| 进行中 | 头像 + 橙色环 | 有目标在跟,光填到进度处 |
| 评估中 | 头像 + 沙金呼吸光晕 | 后台正在判断这轮答案 |
| 已完成 | 头像 + 绿色环(短暂出现) | 目标达成,环随后消失,对话继续 |
| 预算耗尽 | 头像 + 红橙色环 | 用完 budget,需要你决定加预算还是放手 |
hover 头像才显示完整 tooltip — 标题 + 还差什么。不 hover 就不打扰你。这是设计意图。
怎么定一个目标
三种方式,按门槛从低到高:
方式 1 — 让员工自己定
你只要在第一次描述任务时让员工知道这是个长任务:
我要做一个完整的项目:把 README 翻译成英文、提 PR、走 review、合并。这跨多轮,请你用 setGoal 锁定,每轮自我评估,turnBudget=8,autoFollowup 开启。
员工识别到"长任务"+"明确要求 setGoal"两条信号,会自动调用工具创建目标,title 从对话上下文自动归纳。你只需点开它的回答,看见头像旁边多了一圈光,就知道目标已锁。
方式 2 — 直接命令工具
不想让员工判断,你直接告诉它调哪个工具、传什么参数:
请立刻调用 setGoal 工具,title="部署博客到 fly.io",turnBudget=10,autoFollowup=true。不要问任何前置确认。
"不要问前置确认"这一句很重要 — 否则员工会先问"代码在哪?域名是什么?" 它的本能就是先澄清。
方式 3 — 通过 API 程序化创建
对自动化、外部脚本,REST 端点直接可用:
POST /api/v1/goals
{
"conversationId": "conv-xxx",
"title": "部署博客到 fly.io",
"description": "...",
"exitCriteria": "DNS+SSL+健康检查+测试通过",
"turnBudget": 10,
"llmCallBudget": 200,
"autoFollowupEnabled": false
}
agentId/workspaceId由conversationId在服务端派生,请求体里不用传(传了也会被忽略)。完整接口列表见 API 参考。
一个目标里有什么
最少四样:
| 字段 | 含义 |
|---|---|
| 标题 (title) | 短句,光环 hover 时显示 |
| 描述 (description) | 完整诉求 |
| 退出判据 (exitCriteria) | LLM 可读的判据,evaluator 按这个打分(比如 "DNS 配好+测试通过") |
| 预算 (turnBudget + llmCallBudget) | 防失控上限 |
可选:
- 自动延续 (autoFollowupEnabled):开了之后,员工答完一轮如果觉得"还没完成",会自己接着做下一步,不等你催
- 冷却 (followupCooldownSeconds):两次自动延续之间至少隔多久
它在后台是怎么运转的
每次员工回答完一轮,后台会跑一个评估节点。这个节点:
- 取员工这一轮的最终回答 + 最近几条消息上下文
- 调一个轻量 evaluator(建议指向便宜的小模型)问:完成度多少(0~1)?还差什么?该继续还是已完成?
- 把答案写到
mate_agent_goal_event时间线表里 - 决定下一步:完成 / 预算耗尽 / 继续 / 自动延续
关键不变量:评估发生在 final answer 已经串给你看完之后 — 不阻塞用户看回答。你看到回答出现 → 短暂后头像旁边的光环进度变化。
自动延续是怎么发生的
持续模式会持久化下一次执行时间,由 supervisor 发起新的图片段。下面的图内延续流程只适用于 persistentExecution=false 的旧模式:
- 写一条
followup_injected事件到时间线 - 给对话末尾 APPEND 一条用户消息。1.5.0 起,如果目标有清单,这条消息会明确列出还没通过的那几条准则——"5/8 已完成,剩余:① …… ② ……,去做剩下的";没有清单时回退到笼统的 "Continue working on the goal. Still missing: {gap}."
- 让员工再跑一轮 reasoning,这一轮的回答就直接接在第一轮后面
你的体感是:员工答完一段 → 停半拍 → 继续往下做 — 就像一个人做完一步停了一下想了想然后继续。
撞墙了怎么自己爬起来
新增
长任务最怕的不是难,是卡住——撞到迭代上限就停、某一步失败就崩、在一个工具上空转到预算耗尽。这一组机制让员工在这些地方能自己续上、绕开、爬起来,而不是停在那等你再发消息。
撞到迭代上限的硬延续
以前一轮 ReAct 跑满 max_iterations(结束原因 MAX_ITERATIONS_REACHED),目标子系统会跳过这一轮——不评估、不延续,任务就停在那。现在它走一条硬延续路径:把迭代计数清零、清掉"超限草稿",给员工一段全新的满额迭代预算接着干。
这和上面的"自动延续"不一样:自动延续是 evaluator 判"还没完成"后追加一句引导;硬延续是专门应对撞上限,重置的是迭代预算本身。每轮硬延续会吃掉一整段迭代配额,所以有上限——默认每轮最多 1 次(编译期硬顶 3 次),0 = 关闭(回到旧行为:撞上限直接结束这轮)。
停滞检测与重新规划
Plan-Execute 模式下,单个步骤可能抛异常,也可能陷入停滞——在内部工具循环里反复用同样的调用失败、或拿到同样的无新信息的结果,一路烧到工具预算上限才以空结果"完成",然后污染依赖它的后续步骤。
运行时对每轮工具响应做签名检测,两级响应:
- WARN:同一调用重复失败几次后,注入一条系统提示让模型换个思路(同一个调用只提示一次)
- HALT:再撑下去就标记这一步 stuck,结束内循环
一旦某步 HALT 或抛异常,运行时触发重新规划:清空当前计划,把"已完成步骤摘要 + 失败原因 + 绕开坏步骤"作为上下文带回规划节点,重新生成计划。每次运行最多重规划 1 次,UI 会收到 plan_replan 事件(附失败步骤序号、原因)。
元工具回合不计迭代(迭代退款)
渐进式披露里的 load_skill / enable_tool 是配置动作,不是真正干活。如果某一轮 ReAct 里工具调用全是这类元工具,这一轮的迭代计数不递增(退款),免得"只顾加载技能"的模型白白耗光迭代预算。每次运行最多退款 3 次。
多步计划自动派生目标
一个多步(≥2 步)的 Plan-Execute 计划,如果当前对话还没有活跃目标,规划节点会自动建一个目标,以计划的步骤作为验收准则,并广播 goal_created 事件刷新 UI 的目标面板。这样长计划天然就被目标系统的"跟到完成"语义托住。由 mateclaw.goal.auto-goal-from-plan 控制(默认开)。
目标是一份清单(checklist,1.5.0+)
1.4.0 里 evaluator 每轮给一个完成度分数(0~1)和一句"还差什么"。问题是 0.8 到底是什么意思——哪几条做完了、哪几条没做,你看不清。
1.5.0 把它换成清单:目标 = 一组可以逐条独立验证的准则。
evaluator 有两种模式:
| 模式 | 什么时候跑 | 干什么 |
|---|---|---|
| bootstrap(拆解) | 还没有准则时 | 把目标拆成清单,每条初始为"未通过" |
| verdict(裁决) | 已有准则时 | 逐条判:这条满足了吗?给出证据 |
两种模式都用结构化输出——evaluator 必须返回带类型的对象(准则 id + passed + evidence),而不是一段自由文本让我们去猜。
完成判定是确定性的。 只有当每一条准则都通过,才判完成。20 条里过了 19 条(0.95 分)依然是"继续"——差一条就还差一条,没有模糊阈值。
长任务要验证交付证据
异步子任务的 status=succeeded、progress=100 只是运行生命周期终态,不能直接作为准则通过的证据。对文件、长文、测试场景等交付,准则应写成可复核形式,例如“目标文件存在且回读成功”“正文字符数不少于 N”“至少有 N 条独立场景并可枚举”。若写文件或执行工具失败、只生成了提纲、仍有待办项,对应准则必须保持未通过。
怎么给目标加清单——三种途径:
- 创建时直接带——
setGoal工具传criteria: ["DNS 解析正确", "SSL 有效", "测试全绿"],或POST /api/v1/goals传criteria。省去 bootstrap 那一轮。 - 让 evaluator 自己拆——不传 criteria,第一轮评估时 bootstrap 模式自动拆解。
- 运行中追加——
addGoalCriterion工具或POST /api/v1/goals/{id}/criteria,往进行中的目标补一条,不用重开。
一条准则长什么样:
{ "id": "C1", "text": "DNS 解析指向 fly.io", "passed": false, "evidence": "" }id 由服务端分配(C1、C2…),text 是人能看懂、LLM 能判的一句话,passed 是 evaluator 的裁决,evidence 是它给的依据(输出片段、文件摘录等)。清单存在 mate_agent_goal.criteria 列(JSON),通过 GoalResponse.criteria 解析后下发,从不以裸 JSON 字符串暴露。
头像旁的光,hover 出来是一张清单卡
- 没有清单时——一句话 tooltip:标题 + evaluator 写的 gap 文本。
- 有清单时——一张卡片:标题 +
X/Y进度,下面每条准则前一个○(未完成)或✓(绿色已完成,文字带删除线)。
评估中头像周围是沙金色呼吸光晕;完成短暂显示绿色环后消失;预算耗尽变红橙色环。
Evaluator SPI
评估逻辑实现了 Spring AI 的 Evaluator 接口:既能做目标专用的 checklist 裁决(bootstrap / verdict),也能被当成通用评估器复用(把单个目标包成一条准则跑 verdict)。失败的 evaluator 调用照样计入 LLM 预算,所以预算账目是准的。
1.4.0 的目标是"员工记住它在干什么"。1.5.0 的目标是"员工知道具体还差哪几条"。从一个分数,到一份能逐条勾的清单。
内置目标工具(员工可用)
员工的工具集里默认包含以下工具(无需手动绑定,是 agent-wide 系统级工具):
| 工具 | 用途 | 触发提示词示例 |
|---|---|---|
| setGoal | 创建目标 | "请用 setGoal 锁定本次目标,title=..." |
| addGoalCriterion | 追加子准则到已有目标 | "再加一条准则:必须支持 IPv6" |
| completeGoal | 显式标记完成 | "所有事项已做完,请 completeGoal" |
| getGoalStatus | 查询当前 goal 状态 | "我们现在进展到哪了?" |
| waitForGoalInput | 持续目标缺少必要输入时暂停并记录原因 | "缺少部署域名,请等待用户补充" |
完成时(completeGoal,或 evaluator 判定每一条准则都通过),员工会把这个目标的总结同步到长期记忆,后续对话能查得回来。
子员工不能改父员工的目标
多员工协作里 parent 员工可以委派 child 员工干活。Child 看不到这些 goal 工具 — 目标是 parent 会话的状态,child 是无状态的执行体。
这一条是设计意图,不是 bug。child 帮 parent 做事,但目标的"所有权"留在 parent 那。
预算耗尽时
turnsUsed >= turnBudget 或 (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget持续模式仅检查正预算;0 表示不限。达到预算后目标进入 paused,调度状态为 budget_limited,增加预算后可 resume。旧模式仍进入终态 exhausted,需要新建目标才能继续。当前片段的回答仍会保存。
你的选择:
- 加预算 + 恢复 — 通过
PATCH /api/v1/goals/{id}改 budget 后 resume(v1 暂未给 UI 提供按钮,可以走 API 或先 abandon 重新创建) - 放手 — 调 abandon,conversation 上释放槽位,可以重设新目标
状态机
create
↓
active
↓ ↑
paused
active ──evaluator 全部准则通过 / completeGoal──→ completed (终态)
↓
active ──正预算用完(持续模式)───────────→ paused
active ──预算用完(旧模式)───────────────→ exhausted (终态)
↓
active ──user abandon ─────────────────────→ abandoned (终态)终态 (completed / exhausted / abandoned) 不能复活。要继续就开新 goal — 这是有意保留的简单约束,避免 "重启" 带来的预算账目混乱。
一会话一目标:每个 conversation 同一时刻最多一个 active goal。终态 goal 留在历史里不占名额。底层用 H2 / MySQL 的生成列 + 唯一索引保证并发安全,service 层 + DB 层双重防御。
这套系统不做什么
按设计原则保留了几个"不做":
- 不做嵌套目标 / 目标树 — 一个 conversation 一个目标,不堆 OKR
- 不做"目标模板" — 每个目标是手写的,不是从库里挑的
- 不做跨 conversation 迁移目标 — 想要那效果,请用工作流
- 不暴露评估分数给用户 — 那个
completionScore是工程内部协议,不是用户语言。UI 用一圈光说话,hover 出来:有清单时是逐条勾的清单卡,没清单时是 evaluator 写的 gap 文本(自然语言)。后端日志和 API 里仍可见数值,方便调试
完整事件时间线(drawer 抽屉视图)
每个目标都有一份只增不删的事件时间线,按时间倒序展示:
| 事件 | 触发 |
|---|---|
created | setGoal 工具或 REST POST |
evaluated | 每轮答完,evaluator 跑完一次 |
followup_injected | autoFollowup 触发,注入了 prompt |
completed | evaluator 判完成或 completeGoal 工具 |
exhausted | budget 用尽 |
paused / resumed / abandoned | 用户手动操作 |
criterion_added | addGoalCriterion 工具 |
通过 GET /api/v1/goals/{id}/events 拉取(详见 API 参考)。
配置项
application.yml:
mateclaw:
goal:
# 主开关;关闭后图节点对所有调用 pass-through
enabled: true
# 创建目标时 autoFollowupEnabled 的默认值(调用方未指定时)
default-auto-followup: true
# 运行期总开关;关掉则无论 per-goal 标志如何,都不注入自动延续
allow-auto-followup: true
# 单后端实例同时运行的持久化目标 Segment 上限
max-concurrent-segments: 4
# 普通 Segment 结算后再次续跑的全局最小间隔(秒)
minimum-continuation-interval-seconds: 1
# provider 或评估器发生可重试故障后,暂停认领其他目标的时间(秒;0 = 关闭)
provider-failure-global-backoff-seconds: 30
# 新目标默认持续模式;省略预算表示不限(0)
default-persistent-execution: true
supervisor-poll-ms: 5000
# 旧模式的默认 turn 预算
default-turn-budget: 20
# 旧模式的默认 LLM 调用预算(agent + evaluator 之和)
default-llm-call-budget: 200
# 自动延续之间至少隔多久(秒)
auto-followup-cooldown-seconds: 0
# 单次 graph 运行内自动延续的硬上限(每条消息的安全网;总预算仍由 turnBudget 管)
max-followups-per-run: 8
# 撞到迭代上限时每轮最多硬延续几次(0 = 关闭;编译期硬顶 3)
max-hard-continuations-per-run: 1
# 多步 Plan-Execute 计划在无活跃目标时自动派生一个目标
auto-goal-from-plan: true
# 评估器使用的模型;空字符串 = 沿用对话当前模型(便宜的小模型推荐:qwen-turbo / glm-4-flash)
evaluator-model: ""
# 评估 prompt 携带的历史消息条数上限
evaluator-context-messages: 8minimum-continuation-interval-seconds 与目标自身的 followupCooldownSeconds 取较大值。 provider 全局退避只保存在当前后端实例内;重启恢复仍以数据库中的 continuation next_run_at 和每目标失败次数为准。耐久或低配模型测试建议使用并发 1、最小间隔 300 秒、provider 全局退避 300 秒,再根据日志逐步放量。
数据库
相关表使用 mate_ 前缀:
| 表 | 用途 |
|---|---|
mate_agent_goal | 目标本体;含 status / budget / 双 LLM 计数器 / 自动延续配置 |
mate_agent_goal_event | 目标的事件追加日志,drawer 时间线读它 |
持续调度表 mate_goal_continuation 记录队列、到期时间和租约。迁移由 Flyway 跑 V120__agent_goal.sql 和 V188__goal_continuation.sql(H2 / MySQL / KingbaseES 三方言)。
一句话总结
Goal 不是给员工加一个功能。是改它的状态。
以前的员工"答完就忘"。Goal 让员工跨多轮记住一件事 — 它在干什么、还差什么、什么时候算完。你只用说一次。剩下的,让头像旁边那圈光替你跟。
