在VPS上自托管Deer Workflow:运行Agent图
了解如何在Ubuntu VPS上用Bun安装并固定Deer Workflow版本,以systemd无头运行TypeScript agent图,并将机器可读事件流写入日志排查定时失败。
构建内容
Deer Workflow 是一个采用代码优先方式构建 agent 图的运行时:控制流写在可审查的 TypeScript 文件中,coding agent 只执行需要判断的部分。本指南将在一台 Ubuntu VPS 上安装它,在 systemd 下以无头模式运行一个示例图,并将机器可读的事件流写入日志文件。运行在凌晨三点失败时,您可以搜索该文件进行排查。
各组件都很简单。Bun 运行 CLI。一个 coding agent CLI(Codex 或 Claude Code)执行模型相关工作。一个固定版本的 npm 包提供运行时。一个 TypeScript 文件保存图定义。systemd service 和 timer 按计划运行它。本指南的大部分内容用于处理实际容易出错的部分:systemd unit 中的 PATH、没有登录 shell 的会话中的 agent 凭据,以及固定一个于 July 2026 首次发布的依赖版本。
可视化构建器、代码,还是直接向代理编写提示
使用模型自动化工作的自托管用户通常会选择三种方式之一,而每种方式都会以不同形式失败。
可视化构建器提供画布、节点库,以及非程序员也能使用的用户界面。这确实是一个优势,而且同类工具很多,因此可以从完整的 自托管 n8n 替代方案 调查中进行选择。代价是,逻辑最终会变成由用户界面生成的 JSON 文档。该文档的差异内容通常很嘈杂,因此审查变更时必须打开画布,而不是直接阅读补丁。
直接向代理编写提示是第二种方式。你用一段话描述整个任务,然后让模型决定执行顺序、重试方式以及停止时机。这种方式在模型某天做出不同决定之前都能正常工作。由于不存在工件,也就没有差异内容:计划存在于对话中,而对话已经消失。
通过代码编排是第三种方式。步骤顺序、扇出、重试和错误处理都以普通 TypeScript 代码保存在 git 中。只在需要判断的节点调用模型,其他位置不调用。代价是必须有人编写和维护这些代码,而且不会编写 TypeScript 的同事无法编辑它。
图运行时带来的收益与成本
- 可审查的控制流。 图就是一个文件。重试策略的变更会在拉取请求中显示为修改了3行,而不是移动了一个框。
- 纳入版本控制的故障处理。 第4步失败时会发生什么,会与其余基础设施一起被记录、测试并打上标签。
- 可替换的代理。 该运行时为 Codex、Claude Code 和 Pi 提供适配器。更改执行某个步骤的代理,只需修改一条 import。
- 可观察的执行过程。 运行时会以结构化数据输出各个阶段和事件,因此无头运行也会留下可查询的记录。
更普遍的做法是设计模型运行其中的循环,而不是反复润色单个提示词,这称为循环工程;图运行时是实现这一做法的一种具体方式。其成本在于需要进行设置:安装运行时、为代理 CLI 完成身份验证、没有面向非程序员的界面,以及需要持续关注仍处于早期阶段的依赖项。
项目较新,因此应固定版本
Deer Workflow 使用 MIT 许可证,且项目较新。截至 2026 年 8 月 19 日,仓库在 main 上有 47 次提交。npm 上有 3 个已发布版本:0.0.1 和 0.1.0 于 2026 年 7 月 26 日发布,随后 0.2.0 于 2026 年 7 月 27 日发布。每个版本都有对应的 git 标签,变更日志用于查看版本之间的差异。其 Unreleased 部分已经移除了 deer-workflow agent 命令,因此 main 和最新发布版本提供的 CLI 已不再相同。
这不是应该避开该项目的理由。你应安装一个确定的版本,并明确记录所安装的版本。
- 安装确定的版本,绝不要使用版本范围。
- 将该版本记录在存放图表的同一仓库中。
- 每次升级后,在定时器再次运行前,先手动运行一次自己的图表。
安装 Bun 和一个代理运行时
下面的所有操作都应由具有 sudo 权限的普通用户执行。不要以 root 身份运行。代理 CLI 会将凭据存储在登录用户的主目录中,后续的 systemd 单元也必须以同一用户身份运行,才能找到这些凭据。
sudo apt update
sudo apt install -y curl unzip jq git nodejs npm
curl -fsSL https://bun.com/install | bashBun 安装程序会解压 zip 归档,因此必须先安装 unzip。安装程序会将 PATH 配置行追加到 shell 配置文件中,而当前 shell 已经读取过该文件。因此,请打开新的 shell,或者自行将下面两行添加到 ~/.bashrc,然后重新加载该文件。
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$HOME/.npm-global/bin:$PATH"bun --version该命令会输出版本号。bun: command not found 表示当前 shell 中缺少 PATH 配置行,并不表示安装失败。重新安装前,先运行 ls ~/.bun/bin。
下面安装代理运行时。Codex CLI 是默认选项,它通过 npm 安装。设置用户级 npm 前缀,这样全局安装时无需 root 权限。
npm config set prefix "$HOME/.npm-global"
npm install -g @openai/codex
command -v codex
codexcommand -v codex 应输出 $HOME/.npm-global/bin 下的路径。单独运行 codex 会打开 CLI,然后使用 ChatGPT 账户登录。现在完成一次登录,以便能看到屏幕上的提示。
Claude Code 可作为替代运行时,并提供独立的安装程序。
curl -fsSL https://claude.ai/install.sh | bash
claude --version安装成功后会输出类似 2.1.211 (Claude Code) 的版本号。运行 claude 一次以登录。这类进程与您托管的其他代理具有相同的文件访问权限,因此 在 VPS 上运行编码代理 中的账户和加固说明同样适用于此处。
安装 Deer Workflow 并固定准确版本
bun install --global @deerwork-ai/deer-workflow@0.2.0
command -v deer-workflowcommand -v 会输出绝对路径,通常为 /home/<your user>/.bun/bin/deer-workflow。将该路径复制到其他位置。systemd 单元不能使用不带路径的名称。
保留安装命令中的版本号。删除 @0.2.0 后,安装命令会安装运行当天的最新版本。对于已有 47 次提交的项目,这可能导致定时器下运行的 CLI 发生变化,而无人监控这种变化。
将图表放入 git 仓库
mkdir -p ~/workflows/logs
cd ~/workflows
git initCodex 会检查自身是否在 git 仓库中运行,因此 CodexAgentConfig 提供了 skipGitRepositoryCheck 选项,以处理无法为其指定 git 仓库的情况。在您自己的 VPS 上可以指定仓库,而且应该这样做:图表属于代码。如果代码不受版本控制,将编排配置作为代码管理的依据就不成立。现在创建 logs 目录,因为 systemd 不会为您创建该目录。
编写一个图
工作流就是一个普通的 TypeScript 模块。它导出 meta,这是一个包含名称、描述和有序阶段列表的对象;同时将处理程序导出为 default,或导出为命名的 run。在处理程序中调用软件包提供的辅助函数。phase() 标记当前运行所处的阶段,log() 写入进度行,agent() 向编码代理发送一个提示,parallel() 同时运行任务列表,pipeline() 将项目列表依次送入多个阶段。
将其保存为 ~/workflows/log-triage.ts。
import { agent, log, parallel, phase } from "@deerwork-ai/deer-workflow";
export const meta = {
name: "log-triage",
description: "Groups recent service errors and writes one short report.",
phases: [{ title: "Collect" }, { title: "Classify" }, { title: "Report" }],
exampleArgs: { service: "nginx", hours: 24 },
};
export default async function workflow(args: { service: string; hours: number }) {
if (!args?.service) throw new Error("input needs a service name");
phase("Collect");
log(`Reading ${args.hours}h of logs for ${args.service}`);
const found = await agent<{ patterns: string[] }>(
`Read the last ${args.hours} hours of journalctl -u ${args.service} and list the distinct error patterns.`,
{
sandbox: "read-only",
schema: {
type: "object",
properties: { patterns: { type: "array", items: { type: "string" } } },
required: ["patterns"],
additionalProperties: false,
},
},
);
phase("Classify");
log(`Classifying ${found.patterns.length} patterns`);
const notes = await parallel(
found.patterns.map((pattern) => () =>
agent(`Explain this error and its most likely cause: ${pattern}`, { sandbox: "read-only" }),
),
);
phase("Report");
return agent(`Write a short operations report from these notes: ${JSON.stringify(notes.filter(Boolean))}`);
}该文件中有 4 个细节非常重要。
- 在
agent()调用中使用schema可请求结构化输出,该调用会返回解析后的对象。found.patterns是一个真正的数组,图的其余部分可以遍历它。如果没有架构,agent()会返回字符串,你还需要自行解析自然语言文本。 sandbox决定该步骤可以操作的内容。read-only禁止写入,workspace-write允许受保护的写入,danger-full-access则移除保护。它按调用设置,因此图可以广泛读取,并仅在一个位置执行写入。parallel()接收函数,而不是 promise。map((pattern) => () => agent(...))会创建一个 thunk 列表,由运行时决定每个任务何时开始。如果直接传入agent(...),列表构建完成时所有调用都会立即开始。parallel()中的失败任务会变为null,运行仍会继续,因为设计上允许部分完成。因此notes.filter(Boolean)不是装饰性内容:跳过它后,失败分支会将文本null放入下一步的提示中。
普通的 agent() 辅助函数使用默认运行时 Codex。若要改用 Claude Code 执行某一步,请导入代理类并直接调用它。
import { ClaudeAgent } from "@deerwork-ai/deer-workflow";
const claude = new ClaudeAgent({ sandbox: "read-only" });
const summary = await claude.run<string>("Summarise ./report.md in five lines.");这就是实际可替换的代理:只需添加一个导入和一个构造函数,外围图无需修改。CLI 中的 --agent codex|claude|pi 标志属于 deer-workflow create,它会根据描述生成工作流文件。它不会改变 deer-workflow run 使用的运行时。
手动运行一次,然后改用无界面模式
cd ~/workflows
deer-workflow run ./log-triage.ts --input '{"service":"nginx","hours":24}'交互运行时,您会看到终端界面:一侧显示 meta 中的各个阶段,另一侧显示实时日志。在自动化之前,先用这种方式完整运行一次。如果代理未登录,或者您的输入与处理程序签名不匹配,您可以在几秒内发现问题,而不必等到下周再从日志文件中查找。
要实现自动化,请将输入移到文件中。保存 ~/workflows/input.json:
{ "service": "nginx", "hours": 24 }deer-workflow run ./log-triage.ts --input-file ./input.json --print >> logs/run.jsonl--print(简写形式为 -p)会关闭界面,并将事件流写入 stdout,每行一个 JSON 对象。在此模式下,stdout 不会输出其他内容,因此直接追加到 .jsonl 文件后,文件中的每一行都可以解析。
事件流,以及凌晨 3 点该 grep 什么
每一行都包含 type、sequence、timestamp、workflowId、depth 和 scriptPath。类型包括 workflow:start、workflow:meta、workflow:end、workflow:error、workflow:phase:start、workflow:phase:end 和 log。阶段事件包含 phase,结束事件包含 durationMs,log 事件包含 message,workflow:error 事件包含 error,以及 name、message 和通常存在的 stack。
这些结构足以回答凌晨 3 点需要确认的两个问题:它是否已完成,以及它在哪里停止。
grep workflow:error logs/run.jsonl
jq -r 'select(.type == "workflow:error") | .error.message' logs/run.jsonl
jq -r 'select(.type == "workflow:phase:end") | [.phase, .durationMs] | @tsv' logs/run.jsonl
jq -r 'select(.type == "log") | .message' logs/run.jsonl要查看当前正在运行的任务,请跟踪该文件:tail -f logs/run.jsonl | jq -c 'select(.type == "log")'。单次运行只会写入少量行,但文件只会不断增大,因此计时器运行几周后,应为 ~/workflows/logs/*.jsonl 添加 logrotate 规则。
在 systemd 下运行
使用 oneshot 服务配合定时器,而不是运行长期驻留的 daemon。图表任务启动、运行,然后退出。编写 /etc/systemd/system/log-triage.service,将 deploy 替换为您的用户名。
[Unit]
Description=Log triage workflow
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=deploy
WorkingDirectory=/home/deploy/workflows
Environment=HOME=/home/deploy
Environment=PATH=/home/deploy/.bun/bin:/home/deploy/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/deploy/.bun/bin/deer-workflow run ./log-triage.ts --input-file ./input.json --print
StandardOutput=append:/home/deploy/workflows/logs/run.jsonl
StandardError=journal
TimeoutStartSec=3600然后执行 /etc/systemd/system/log-triage.timer:
[Unit]
Description=Run the log triage workflow every night
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl start log-triage.service
systemctl status log-triage.service
sudo systemctl enable --now log-triage.timer
systemctl list-timers log-triage.timer先手动启动服务。正常运行会以单元成功停用结束,并且 logs/run.jsonl 会新增一组以 workflow:end 结尾的事件。确认这一点后,再启用定时器。list-timers 会显示下一次计划运行时间;Persistent=true 表示服务器关机期间错过的运行会在下一次启动时执行一次。StandardOutput=append: 会将事件流写入文件,并将其他内容保留在 journal 中,因此 journalctl -u log-triage.service 保持可读。
为什么图在 shell 中可以运行,但在 systemd 下失败?
按以下顺序检查这四项。
单元找不到二进制文件。 systemd 不会读取 ~/.bashrc,其默认 PATH 中也不包含 ~/.bun/bin 或 ~/.npm-global/bin。单元会在不到 1 秒内失败,journalctl -u log-triage.service 会显示按命令名称执行失败。这就是 ExecStart 使用绝对路径的原因,也是 Environment=PATH= 仍列出这两个目录的原因:运行时启动代理步骤时,本身必须找到 codex 或 claude。
代理找不到凭据。 代理 CLI 从主目录读取登录信息,因此请显式设置 User= 和 Environment=HOME=,并为它指定登录时使用的主目录。如果运行已到达 workflow:start,随后生成的 workflow:error 中的消息来自代理 CLI,而不是您自己的代码,问题几乎总是出在这里。
运行在 90 秒后被终止。 对于 Type=oneshot,systemd 会将启动超时应用于整个命令,默认值为 90 秒。代理图运行需要几分钟。日志会记录 Start operation timed out. Terminating.,单元进入 failed 状态,而日志文件只包含未完成运行的部分内容,没有 workflow:end。TimeoutStartSec=3600 会将超时时间设为 1 小时。如果希望它永不因超时被终止,请使用 infinity。
相对路径解析到了其他位置。 ./log-triage.ts 和 ./input.json 是相对于 WorkingDirectory 的路径。省略该行后,systemd 会在 / 中启动进程,而这两个文件都不存在。
编排器可以执行哪些操作
按计划运行代理步骤的编排器,是一个在无人监控的情况下操作服务器的进程。需要关注两个控制点和一项预算。
第一个控制点是每次 agent() 调用使用的沙箱。对于只读步骤,read-only 是正确的默认设置,例如读取日志、指标或需要汇总的代码仓库。只有在确实需要写入时,才将某一步切换到 workspace-write;应使用 additionalWritableDirectories 限制可写区域,而不是直接使用 danger-full-access。
第二个控制点是人工审批。有些步骤绝不能无人值守运行:发送邮件、转移资金、删除数据或修改生产配置。在代码优先的工作流中,审批门很容易放置,因为步骤就是一行代码。停止运行,记录拟执行的操作,等待人工答复,然后继续执行。在代理操作前设置审批门完整介绍了这种模式;凡是由计时器启动的工作流,都应包含这种控制。
预算指的是费用。每次 agent() 调用都会启动一个完整的代理会话,而 parallel() 会同时启动多个会话。因此,一个分支为 12 个分支的工作流每晚都会运行 12 个会话,无论是否有人查看报告。在 VPS 上控制 AI 代理成本中介绍的测量方法和限制同样适用于计划运行的工作流。
升级运行时之前,请阅读变更日志,安装新的确切版本,然后使用 --print 手动运行一次工作流。这个项目还很早期,CLI 接口仍在变化:Unreleased 部分已经删除了一个在 0.2.0 中存在的命令。由计时器运行的工作流,其可靠性取决于锁定的版本,以及你实际监控过的最近一次运行。
FAQ
我需要 Bun,还是 Node.js 就能运行 Deer Workflow?
请安装 Bun。已发布的软件包将其 deer-workflow 二进制文件指向 src/cli.ts(一个 TypeScript 源文件),文档也将 Bun 列为前置条件。Bun 可直接执行 TypeScript,因此不需要构建步骤。先使用 sudo apt install -y unzip 安装,再执行 curl -fsSL https://bun.com/install | bash,然后通过 bun --version 验证。若从 npm 安装 Codex CLI,仍需单独安装 Node.js 和 npm。
为什么工作流在终端中可以运行,但在 systemd 下失败?
几乎总是 PATH、HOME 或启动超时导致。systemd 不会读取 shell 配置文件,因此 ExecStart 需要指向 deer-workflow 的绝对路径,而 Environment=PATH= 需要指向包含 codex 或 claude 的目录。代理 CLI 从 $HOME 读取凭据,因此将 User= 和 Environment=HOME= 设置为您登录所用的账户。Type=oneshot 继承了 90 秒的启动超时;超时会在代理运行过程中终止任务,并在日志中留下 Start operation timed out. Terminating.,因此请设置 TimeoutStartSec=3600。
如何在某个步骤中使用 Claude Code,而不是 Codex?
普通的 agent() 辅助函数使用默认运行时 Codex。从软件包中导入 ClaudeAgent,创建其实例,然后对需要由 Claude Code 处理的步骤调用 .run()。--agent codex|claude|pi 标志属于 deer-workflow create,也就是根据描述生成工作流文件的命令;它不会影响 deer-workflow run。无论使用哪个代理,都必须安装对应的 CLI,并以服务运行所用的同一用户身份登录。
应安装 Deer Workflow 的哪个版本?
安装您测试过的确切版本。截至 2026 年 8 月 19 日,最新发布版本为 0.2.0,发布日期是 2026 年 7 月 27 日,代码仓库包含 47 次提交。将 @0.2.0(或您阅读本文时的当前版本)写入安装命令,在 git 中将该版本号与图一同保存。每次升级后,先手动运行一个图,再让定时器运行它。