如何用 OpenRouter SDK 搭建常驻 AI Agent

发布时间 2026-10-31
来源 OpenRouter Blog
字数 5,187 字
查看原文

智能总结

OpenRouter 发布教程,用 TypeScript 和 Agent SDK @openrouter/agent 0.11.0 搭建常驻 AI Agent,可按计划唤醒,监控 Hacker News 指定主题并生成摘要。

OpenRouter · 2026 年 10 月 31 日

How to Build Your Own Always-On AI Agent

简要说明(Tl;dr)——常驻 AI Agent 需要什么?——开始之前——第 1 步:给 Agent 提供工具——第 2 步:编写 Agent 的一次运行——第 3 步:让它持续运行——第 4 步:验证它能在重启后存活——第 5 步:审批它想做的事——第 6 步:把它部署到常开的环境——常驻 AI Agent 的成本是多少?——常见问题——后续步骤——参考资料

常驻 AI Agent 是一个按计划自动唤醒的程序:它使用工具查看某些信息,判断哪些内容重要,并采取行动或向你汇报,而无需你每次都手动触发。在 2026 年的 DevDay 上,OpenAI 发布了 Dots,这是他们版本的常驻 Agent。你也可以自己搭建一个,自行选择模型、工具,以及每次运行可花费的额度上限。

在本教程中,我们用 TypeScript 和 OpenRouter Agent SDK 搭建一个常驻 Agent:它监控 Hacker News 上你选定的主题,把值得阅读的内容写成本文摘要,在重启后仍记得已经看过哪些内容,并且当任何一次运行的花费达到 5 美分时立即停止。每次检查的花费在 0.0003 到 0.0012 美元之间。同样的结构适用于任何应该持续运行的 Agent,例如监控某个代码仓库或状态页面并采取行动的 Agent。

简要说明(Tl;dr)

  • 常驻 AI Agent 按计划唤醒,用工具检查某些内容,无需每次被要求就自行汇报或行动。本教程用我们的 Agent SDK @openrouter/agent 0.11.0 以 TypeScript 搭建一个这样的 Agent。
  • 在 2026 年 10 月 13 日的运行中,使用 Claude Haiku 5.5 时,每次检查花费 0.0003 到 0.0012 美元;若每 15 分钟检查一次,每月约 0.86 到 3.46 美元。
  • stopWhen: [stepCountIs(6), maxCost(0.05)] 为每次运行设定上限,API 密钥上的额度限制则限定了总花费。
  • 在工具上设置 requireApproval: true 会在该工具执行前暂停运行;下次运行会根据你对这次调用的批准或拒绝结果按 ID 恢复。
  • 一个记录已见故事 ID 的文件就是这个 Agent 的长期记忆;每次检查都开启全新的对话,可以让每次检查的成本在数周运行中保持稳定。

常驻 Agent 需要什么?

常驻 Agent 需要四样东西,每样只需几行代码:

  1. 一个按计划运行 Agent 的循环。
  2. Agent 用来观察世界并采取行动的工具。
  3. 能在崩溃或重启后存活的记忆。
  4. 限制:对每次运行的步骤数和花费设上限,并对任何有后果的操作要求人工审批。

Flow of one scheduled agent check: a timer starts the run, it is skipped while an approval is pending, otherwise callModel runs with tools and limits, writes the digest, and updates the memory files.

一次按计划执行的检查。有审批待处理时这次运行会被跳过;否则它会开启一段全新的对话,并受到各项限制的约束。

我们的 Agent SDK 负责运行模型和工具调用、应用限制并保存对话,使暂停的运行能够恢复;而你的代码负责调度和记录 Agent 已经看过的故事文件。如果你想了解 SDK 为你做了什么,我们关于构建工具调用 Agent 循环的教程手工写了同样的循环。

开始之前

你需要 Node.js(我们用的是 22 版)和一个 OpenRouter API 密钥。如果还没有密钥,快速入门会带你创建一个。

创建一个项目,安装 SDK、Zod(SDK 用它定义工具的输入 schema)和 tsx(直接运行 TypeScript),然后让脚本能够读取你的密钥:

mkdir watcher && cd watcher
npm init -y
npm pkg set type=module
npm install @openrouter/agent@0.11.0 zod tsx
export OPENROUTER_API_KEY="your-openrouter-key"

把 type 设为 module 可以让脚本使用顶层 await。SDK 仍处于 0.x 版本,因此命令固定使用 0.11.0。

如果你使用 Bun,bun add @openrouter/agent@0.11.0 zod 会安装脚本所需的全部依赖,并且在本教程运行 npx tsx agent.ts 的地方,你可以运行 bun agent.ts。

第 1 步:给 Agent 提供工具

这个 Agent 会获得三个工具:

  • check_hacker_news 通过 Hacker News 搜索 API 搜索最近的帖子,只返回 Agent 尚未看过的那些。
  • write_digest 保存一条供你阅读的条目。
  • post_to_team_channel 会触达其他人,因此需要你批准后才能运行。

每个工具都有名称、描述、用于输入的 Zod schema,以及一个 execute 函数。如果你来自其他框架,我们关于各 Agent 框架的工具调用 schema 处理方式对比展示了六种框架如何定义相同的组成部分。创建 agent.ts,从导入、设置、记忆和工具开始:

import { OpenRouter, tool, stepCountIs, maxCost, type ConversationState, type StateAccessor } from "@openrouter/agent";
import { z } from "zod";
import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync } from "node:fs";

const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });
const MODEL = "anthropic/claude-haiku-5.5";
const TOPICS = "new AI models, AI agents, and tools for developers who build with LLMs";
const EVERY_MS = Number(process.env.EVERY_MS ?? 15 * 60_000);
const MAX_RUNS = Number(process.env.MAX_RUNS ?? Infinity);

// Memory on disk: which stories the agent has already seen, and the conversation itself.
const SEEN = "seen.json";
const seen: Set<string> = new Set(existsSync(SEEN) ? JSON.parse(readFileSync(SEEN, "utf8")) : []);
// Stories returned during the current run. They join seen.json only when the run finishes,
// so a run that crashes partway doesn't lose them. fetched.json keeps them across a restart.
const FETCHED = "fetched.json";
const fetched: Set<string> = new Set(existsSync(FETCHED) ? JSON.parse(readFileSync(FETCHED, "utf8")) : []);
function markSeen() {
  fetched.forEach((id) => seen.add(id));
  fetched.clear();
  writeFileSync(SEEN, JSON.stringify([...seen]));
  rmSync(FETCHED, { force: true });
}
const STATE = "state.json";
const state: StateAccessor = {
  load: async () => (existsSync(STATE) ? (JSON.parse(readFileSync(STATE, "utf8")) as ConversationState) : null),
  save: async (s) => writeFileSync(STATE, JSON.stringify(s)),
};

const checkHackerNews = tool({
  name: "check_hacker_news",
  description: "Return Hacker News stories from the last 48 hours with at least 20 points that you haven't seen before.",
  inputSchema: z.object({ query: z.string().describe("Search terms") }),
  execute: async ({ query }) => {
    const since = Math.floor(Date.now() / 1000) - 2 * 86400;
    const url = `https://hn.algolia.com/api/v1/search?tags=story&hitsPerPage=20&query=${encodeURIComponent(query)}&numericFilters=${encodeURIComponent(`created_at_i>${since},points>=20`)}`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HN search failed: ${res.status}`);
    const hits = (await res.json()).hits as any[];
    const fresh = hits.filter((h) => !seen.has(h.objectID) && !fetched.has(h.objectID));
    fresh.forEach((h) => fetched.add(h.objectID));
    writeFileSync(FETCHED, JSON.stringify([...fetched]));
    console.log(`  check_hacker_news("${query}") -> ${fresh.length} new`);
    return fresh.map((h) => ({ id: h.objectID, title: h.title, points: h.points, comments: h.num_comments, url: `https://news.ycombinator.com/item?id=${h.objectID}` }));
  },
});

const writeDigest = tool({
  name: "write_digest",
  description: "Add a short digest entry for the user. Use only for stories that are genuinely relevant.",
  inputSchema: z.object({ markdown: z.string() }),
  execute: async ({ markdown }) => {
    appendFileSync("digest.md", `\n## ${new Date().toISOString()}\n${markdown}\n`);
    console.log("  write_digest -> digest.md");
    return { saved: true };
  },
});

const postToTeam = tool({
  name: "post_to_team_channel",
  description: "Post a message to the whole team. Only for news the whole team must see today.",
  inputSchema: z.object({ message: z.string() }),
  requireApproval: true,
  execute: async ({ message }) => {
    console.log(`  post_to_team_channel -> "${message}"`); // call your chat API here
    return { posted: true };
  },
});

const tools = [checkHackerNews, writeDigest, postToTeam] as const;

该工具会在把结果交给模型之前,过滤掉 Agent 已经看过的帖子,因此模型无需追踪此前已汇报过的帖子,也不必冒重复汇报同一帖子的风险。

新的 ID 只有在一次运行结束后才会写入 seen.json,所以中途失败的一次运行不会丢它的帖子。在此之前,它们保存在 fetched.json 中,该文件在重启后仍然存在。在测试中,我们在一次返回 20 条新帖子的搜索之后立即杀掉了进程。下一次运行延续了未完成的对话(第 2 步),根据保存的搜索结果写出了摘要,并把全部 20 个 ID 加入了 seen.json,之后的检查发现了 0 条新帖子。代价是:如果一次运行在写出摘要之后失败,可能会把某个帖子列两次。修复重复内容比丢失帖子更容易。