如何在VPS上自托管Deer Workflow并运行Agent图
使用Bun在Ubuntu VPS安装Deer Workflow,固定2026年7月首次发布的npm版本,并通过systemd无头运行TypeScript Agent图,将机器可读事件写入日志。
构建内容
Deer Workflow 是一个面向代码的 agent 图运行时:控制流保存在可审查的 TypeScript 文件中,coding agent 只执行需要判断的部分。本指南将在一台 Ubuntu VPS 上安装它,在 systemd 下以无头模式运行一个示例图,并将机器可读的事件流写入日志文件。运行在凌晨三点失败时,您可以搜索该文件进行排查。
这些组件都很小。Bun 运行 CLI。一个 coding agent CLI(Codex 或 Claude Code)执行模型工作。一个固定版本的 npm 包提供运行时。一个 TypeScript 文件保存您的图。systemd 服务和 timer 按计划运行它。本文的大部分篇幅用于介绍最容易出问题的部分:systemd unit 中的 PATH、没有登录 shell 的会话中的 agent 凭据,以及固定一个首次发布于 2026 年 7 月的依赖项版本。
可视化构建器、代码,还是直接向代理提示
使用模型自动化工作的自托管方案通常有三种形式,而它们会以不同方式失败。
可视化构建器提供画布、节点库,以及非程序员也能使用的用户界面。这是一个实际优势,而且该领域的工具足够多,可以从完整的自托管 n8n 替代方案列表中进行选择。代价是,逻辑最终会变成由用户界面生成的 JSON 文档。该文档的差异内容通常很嘈杂,因此审查变更时必须打开画布,而不是直接阅读补丁。
直接向代理提示是第二种形式。您用一段话描述完整任务,再让模型决定执行顺序、重试方式以及何时停止。这种方式在模型始终按预期执行之前都有效;但模型可能在某一天做出不同决定。这里没有差异内容,因为不存在构件:计划保存在对话中,而对话已经消失。
代码编排是第三种形式。步骤顺序、扇出、重试和错误处理都以普通 TypeScript 代码保存在 git 中。只在需要判断的节点调用模型,其他位置不调用。代价是必须有人编写和维护这些代码,而不会编写 TypeScript 的同事无法编辑它。
图运行时的收益与成本
- 可审查的控制流。 图就是一个文件。重试策略的变更会在拉取请求中显示为修改了 3 行,而不是移动了一个框。
- 纳入版本控制的故障处理。 第 4 步失败时会发生什么,都有明确记录,经过测试,并与其余基础设施一起打标签。
- 可替换的代理。 该运行时为 Codex、Claude Code 和 Pi 提供适配器。只需导入一个模块,即可更改执行某一步的代理。
- 可监控的执行过程。 运行时会以结构化数据输出各阶段和事件,因此无头运行也会留下可查询的记录。
这种通用实践不是反复打磨单个提示词,而是设计模型运行所处的循环,称为 循环工程;图运行时是实现这一实践的一种具体方式。它的成本在于需要进行设置:安装运行时、为代理 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是一个真正的数组,图中的其他部分可以遍历它。如果没有 schema,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 执行某个步骤,请导入 agent 类并直接调用它。
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.");这就是可替换 agent 的实际用法:只需增加一个导入和一个构造函数,外围图无需修改。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 中的各个阶段,另一侧显示实时日志。在自动化之前,先用这种方式完整运行一次。如果 agent 未登录,或者您的输入与处理程序签名不匹配,您可以在几秒内看到问题,而不必等到下周再从日志文件中查找。
要实现自动化,请将输入移到文件中。保存 ~/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 服务和计时器,而不是长期运行的守护进程。图表启动、运行并退出。创建 /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= 仍然列出这两个目录的原因:运行时启动 agent 步骤时,必须能找到 codex 或 claude。
agent 找不到凭据。 agent CLI 会从主目录读取登录信息,因此请显式设置 User= 和 Environment=HOME=,并将其主目录设为您登录时使用的目录。如果运行已到达 workflow:start,随后生成 workflow:error,且其消息来自 agent CLI 而不是您自己的代码,问题几乎总是出在这里。
运行在 90 秒后被终止。 对于 Type=oneshot,systemd 会将启动超时应用于整个命令,默认值为 90 秒。agent 图通常需要数分钟。日志会记录 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() 会同时启动多个会话。因此,一个分发为十二个分支的流程图每晚会运行十二个会话,无论是否有人阅读报告。在 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?
安装您测试过的确切版本。截至 19 August 2026,最新发布的版本是 0.2.0,发布日期为 27 July 2026,代码仓库包含 47 次提交。请将 @0.2.0(或您阅读本文时的当前版本)写入安装命令,将该版本号与工作流图一起记录在 git 中,并在每次升级后手动运行一个工作流,确认无误后再让定时器运行它。