教程展示如何构建能自动调度执行、自我迭代的 AI Agent,实现真正的自主化工作流自动化。
Bedrock AgentCore 与网页抓取技巧
我想要一个为我工作的 AI 智能体。我希望它能搜索最新新闻,并通过每日消息发送给我。我希望能与它聊天并获取建议。我还希望以后可以扩展它的能力。最重要的是,我希望它受我控制。简单来说,我想要 Jarvis。
没错,就是 Jarvis——Marvel 电影宇宙(MCU)中的虚构 AI 角色,也是 Tony Stark 的管家。我从未拥有过自己的智能体管家,现在想想,谁会不想要呢?😆 几年前,我会告诉你这种事永远不可能发生。但如今,随着智能体的兴起,未来已经到来。
好吧,某种程度上算是……不过,我们确实比以往任何时候都更接近了!
要完成这项任务,最显而易见的方案之一,就是使用 OpenClaw 之类的工具。但 OpenClaw 太新了,而且对于我的需求来说有点过于复杂,所以,像任何一位优秀的工程师一样,我决定自己动手实现。OpenClaw 是一个开源的自主 AI 智能体,可在你的本地计算机或 VPS 上运行,用于自动执行任务。
最近,Amazon Bedrock AgentCore 发布了其托管式 harness 的预览版。你只需在配置文件和系统提示词中定义智能体,然后将其部署即可。就是这么简单!根据我的调研,它看起来非常容易上手,从长远来看还能为我节省大量时间。因此,我决定踏上打造专属 Jarvis 的旅程。
如果你想更深入地了解 harness 到底是什么,我的朋友 Morgan 在 dev.to 上的文章对此讲解得非常清楚。本文则侧重于动手构建。
经过几个小时的研究和折腾,我创建了“The Pulse”。它会在真实浏览器中打开 Hacker News,通过 RSS 获取 Reddit 帖子,将所有内容保存到持久化存储中,并且每隔 6 小时撰写一份热门报道摘要。它每天都会给我发消息,我也可以随时向它发送消息,询问 AI 领域正在发生什么。它会读取一整天持续收集的数据。
如果你和我一样更喜欢视频,可以查看下方介绍如何开始使用的完整视频。我也附上了源代码,方便你亲自尝试。否则,请继续阅读,全面了解如何开始!也欢迎随时告诉我你的使用体验。
aws-samples / sample-AgentCore-Managed-Harness-News
这是一个 AgentCore 托管式 harness 示例项目,它会在 Amazon Bedrock AgentCore 上运行一个定时新闻摘要智能体(“The Pulse”),并将结果发布到 Telegram。
该智能体使用 EventBridge Scheduler 进行调度,通过 Lambda 触发器调用,并使用 AgentCore Memory 在多次运行之间保留上下文。这是一个在 AWS 上构建周期性运行、长时间运行智能体的真实端到端示例。
AgentCore-News-Sample/
├── agentcore/
│ ├── agentcore.json # Project config (runtimes, memories, etc.)
│ ├── aws-targets.json # AWS account + region to deploy into
│ ├── cdk/ # CDK infrastructure (@aws/agentcore-cdk)
│ │ ├── bin/cdk.ts # CDK entry — synthesizes both stacks
│ │ ├── lib/cdk-stack.ts # AgentCore stack (agent, memory, roles)
│ │ ├── lib/scheduler-stack.ts # Scheduler + Lambda trigger
│ │ └── lambda/pulse-trigger/ # Lambda that invokes the harness
│ └── .llm-context/ # Type definitions for AI coding assistants
AgentCore Harness 目前处于预览阶段。CLI、定价以及部分 API 可能会发生变化。请查阅 AgentCore 文档以获取最新信息。
为什么不直接使用 Lambda?
创建 Harness 项目
测试每小时数据收集
测试摘要简报
在不消耗 Token 的情况下导出数据
使用 EventBridge 和 Lambda 进行调度
如果这张图看起来让人望而生畏,也不用担心!其中最复杂的部分,只是让 EventBridge 使用正确的 IAM 角色连接到 harness;如果你刚开始接触基础设施代码,还需要处理这部分代码。让我们来拆解一下这里的流程。
EventBridge 每小时触发一次,并启动一个 Lambda。EventBridge 是 AWS 的调度服务,你可以把它理解为云端的 cron 任务。Lambda 会向 harness 发起一次 API 调用。在 harness 内部,智能体运行于自己的 microVM 中。它本质上是一台轻量级虚拟机,专门为你的会话启动,拥有独立的 CPU、内存和文件系统。智能体会打开浏览器、抓取 HN、获取 Reddit RSS,并保存一份快照。每运行 6 次,它就会读取所有快照、发现趋势并撰写一份摘要。由于多次运行使用相同的会话 ID,所有数据都会累积在 /mnt/data 中。你也可以随时通过 CLI 直接与它交谈,询问当前有哪些热门趋势。
为什么不直接使用 Lambda?
在深入细节之前,你可能会有一个疑问:难道不能只用一个 Lambda、一些 Strands Agents 代码、一个 LLM,再加上一腔梦想来完成所有这些事情吗?对于简单的一次性任务,你的想法可能没错。但这个智能体需要一些 Lambda 无法提供的能力,因此采用这种方案非常值得。
首先,智能体需要打开真实浏览器来抓取 Hacker News。Lambda 无法启动托管浏览器会话。你需要使用 Fargate 加 Playwright,这样一来就得自己管理容器。我很喜欢容器,但在这个场景中我们还是跳过它吧。
其次,它会将每小时的快照保存到 /mnt/data/runs/,并在 6 小时后重新读取。Lambda 的 /tmp 会在每次调用后被清空。你需要使用 S3,并为每个文件操作编写自定义读写逻辑。对于本应只是“保存一个文件”的操作来说,这可要做太多工作了。
第三,每 6 小时执行一次的摘要任务会读取所有累积数据,比较各个快照中的分数、识别趋势并撰写摘要。这可能需要较长时间。Lambda 的最长运行时间为 15 分钟。harness 默认每次调用的超时时间为 1 小时(我将其设置为 30 分钟,因为这已经绰绰有余),而 microVM 本身可以在多次调用之间持续存活最长 8 小时。
此外,还有记忆能力。AgentCore Memory 是一项内置服务,可跨会话存储事实、偏好和对话摘要。因此,“我昨天问过什么?”这样的问题能够得到回答,因为记忆会将上下文延续下去。如果使用 Lambda,你需要 DynamoDB 外加自定义集成。
你能否使用 Lambda、S3、Fargate、DynamoDB 和 Step Functions 构建所有这些功能?当然可以。但这意味着要使用五项服务,而不是一项。对于本教程来说,多出来的四项服务实在太多了。
已在预览版支持的区域(us-west-2、us-east-1、eu-central-1 或 ap-southeast-2)配置 AWS 凭证
已在你的账户中引导 CDK(cdk bootstrap)。CDK 是 AWS Cloud Development Kit。它允许你使用 TypeScript 定义基础设施,而无须在控制台中逐项点击配置。如果你之前没有使用过它,请运行 npm install -g aws-cdk,然后在你的账户中运行 cdk bootstrap。
npm install -g @aws/agentcore@preview
在所在区域拥有 Claude Sonnet 4.6 的 Bedrock 模型访问权限。截至 2025 年末,Bedrock 会自动授予你访问所在区域全部无服务器模型的权限,无须再逐个启用。如果你的组织使用 IAM 策略或 SCP 限制模型访问权限,请确保 Sonnet 未被屏蔽。你可以打开 Bedrock 控制台,并在 playground 中尝试该模型进行验证。
创建 Harness 项目
有趣的部分从这里开始。我们将创建自己的第一个托管式 harness。
agentcore create \
--name thepulse \
--model-provider bedrock \
--session-storage-mount-path /mnt/data
cd thepulse
这一条命令就会搭建出整个项目,包括 harness 配置、记忆、会话存储、CDK 应用和 IAM 角色。--session-storage-mount-path 标志会配置持久化存储,让平台为你创建 /mnt/data 挂载点。你不需要操心 Dockerfile,也不需要管理容器。
现在添加浏览器工具。AgentCore Browser 是一个托管式无头 Chrome 实例,harness 可以按需启动它。你的智能体可以浏览页面、点击元素和提取内容——基本上能完成你使用 Playwright 所做的一切,但无须自己管理浏览器基础设施。
agentcore add tool --harness thepulse --type agentcore_browser --name browser
最终会得到以下目录结构:
thepulse/
├── agentcore/
│ ├── agentcore.json # Project-level resource config
│ ├── aws-targets.json # Deployment targets (account, region)
│ ├── .env.local # Local dev config (gitignored)
│ └── cdk/ # CDK TypeScript app
├── app/
│ └── thepulse/
│ ├── harness.json # Agent config
│ └── system-prompt.md # Agent instructions
AgentCore CLI 已经为我们设置好了模型、记忆和会话存储。你只需调整执行限制并编写系统提示词。
编辑 app/thepulse/harness.json:
{
"name": "thepulse",
"model": {
"provider": "bedrock",
"modelId": "global.anthropic.claude-sonnet-4-6"
},
"tools": [
{
"type": "agentcore_browser",
"name": "browser"
}
],
"skills": [],
"memory": { "name": "thepulseMemory" },
"sessionStoragePath": "/mnt/data",
"maxIterations": 75,
"timeoutSeconds": 1800
}
这里有几点值得一提。
我将 maxIterations 设置为 75,因为浏览器交互会产生大量对话,很快就会消耗掉迭代次数。75 次迭代为完整抓取 HN 浏览器数据、获取 Reddit RSS,以及执行文件操作留出了足够空间,避免 AI 智能体触及上限。
timeoutSeconds 设置为 1800(30 分钟)。浏览器会话需要时间,摘要运行也会读取大量数据。测试时我没有遇到太多超时,但这项调整是为了以防万一。
接下来的这个设置非常实用。设置 sessionStoragePath 后,平台会自动创建 /mnt/data 挂载点。无论会话是否超时,这个挂载点都会按会话共享。
现在编辑 app/thepulse/system-prompt.md。CLI 已经创建了这个文件,并提供了默认提示词。将其替换为:
You are The Pulse, an AI research agent that monitors Hacker News
and Reddit for AI/ML stories.
You have two modes:
### HOURLY COLLECTION
When the prompt says "hourly run":
**Hacker News (browser):**
1. Use the browser tool to open https://news.ycombinator.com
2. Extract the top 30 stories: title, score, comment count, URL
3. Close the browser session when done
**Reddit (RSS — do NOT use the browser):**
Reddit blocks automated browser access (403). Use the RSS feed instead:
1. Shell: `curl -s -A 'ThePulse/1.0'
'https://www.reddit.com/r/MachineLearning/.rss'`
2. Parse the XML to extract post titles, URLs, authors, and dates
3. Note: RSS does not include upvote scores. That's expected.
4. Also try r/artificial: `curl -s -A 'ThePulse/1.0'
'https://www.reddit.com/r/artificial/.rss'`
**Save the snapshot:**
1. Combine HN and Reddit data into a single JSON file
2. Save to /mnt/data/runs/YYYY-MM-DD-HH.json
3. If a previous hour's snapshot exists (check /mnt/data/runs/),
compare and flag:
- Stories that appeared in both snapshots (persistent)
- HN stories with rising scores
### SUMMARY DIGEST
When the prompt says "generate summary":
1. Read all snapshots from /mnt/data/runs/ for the last 6 hours
2. Identify stories that appeared in multiple snapshots
(persistent presence = trending)
3. Identify stories with rising scores across snapshots
4. Deduplicate and rank by persistence, score growth, and
comment activity
5. Write a markdown digest to
/mnt/data/digests/YYYY-MM-DD-{morning|afternoon|evening|night}.md
6. Include the top 10 trending stories with: title, source
(HN/Reddit), peak score, trend direction, URL
### INTERACTIVE
When a user asks a question:
- Read from /mnt/data/runs/ and /mnt/data/digests/ to answer
- Use memory for conversational context
## Important
- /mnt/data is persistent session storage. Use the same session ID
across runs so data accumulates.
- Always create directories before writing:
`mkdir -p /mnt/data/runs /mnt/data/digests`
我将数据采集设置为每小时一次,将快照汇总设置为每六小时运行一次。现在回头来看,这会产生大量数据,我当然可以把间隔设置得更长一些。我认为每天采集两次、每天创建一次快照也完全没问题。你可以根据自己的喜好调整这个提示词。
这里有一个坑,我想提前提醒你:Reddit 不喜欢 AI 智能体抓取它的数据。AI 智能体一直尝试在浏览器中打开 Reddit,收到 403 错误后还会消耗 5~6 次迭代才放弃。我坐在那里看着它一遍遍重试,心里只想着:“求你别再试了。”reddit.com/r/MachineLearning/.rss 的 RSS Feed 每次都能正常工作。把这一点明确写进系统提示词,可以让你免受这种折磨。
agentcore deploy
只需一条命令。它会配置执行框架、内存、IAM 角色、会话存储,以及底层的 microVM 基础设施。这个过程大约需要 3~5 分钟,因为它还会启动 AgentCore Memory,并运行 CDK 来创建 IAM 角色。
你可以验证创建了哪些资源:
agentcore status --json
测试每小时采集
如前所述,为了让 /mnt/data 中的数据在多次运行之间持续累积,测试时需要一个固定的会话 ID。会话 ID 至少需要 33 个字符(UUID 有 36 个字符,因此可以使用)。这一点很重要,因为相同的会话 ID 意味着使用相同的文件系统。数据就是通过这种方式随时间不断累积的。
SESSION_ID="thepulse-$(uuidgen)"
echo $SESSION_ID # Save this. You'll reuse it for every run.
agentcore invoke --harness thepulse \
--session-id "$SESSION_ID" \
--actor-id scheduler \
"Hourly run. Collect AI/ML stories from Hacker News and Reddit. Save the snapshot."
AI 智能体会打开一个托管的 Chrome 浏览器,访问 news.ycombinator.com,提取新闻内容,通过 RSS 获取 Reddit 数据,并将所有内容保存到 /mnt/data/runs/。
agentcore invoke --exec --harness thepulse \
--session-id "$SESSION_ID" \
"ls -la /mnt/data/runs/"
这个 --exec 标志是我最喜欢的执行框架功能之一。它会直接在 microVM 上运行 shell 命令,不会连接任何 LLM,因此不会消耗 token。你可以随时用它检查文件系统,而且不会产生费用。
测试摘要简报
运行几次采集任务(或者等待几个小时),然后执行:
agentcore invoke --harness thepulse \
--session-id "$SESSION_ID" \
--actor-id scheduler \
"Generate summary. Read all snapshots from the last 6 hours and write a trending stories digest."
AI 智能体会读取所有每小时快照,识别多次出现的新闻(即热门趋势),标记分数上升的新闻,并生成一份 Markdown 摘要简报。
agentcore invoke --harness thepulse \
--session-id "$SESSION_ID" \
--actor-id demo-user \
"What's trending in AI right now?"
AI 智能体会读取 /mnt/data/runs/ 和 /mnt/data/digests/ 中的数据,并基于真实数据作答。不是凭空编造的摘要,而是来自它所采集快照的实际分数和 URL。我非常喜欢这一点。AI 智能体不是在猜测,而是在读取它当天早些时候写入的文件。
使用 EventBridge 和 Lambda 设置定时任务
agentcore create 命令已经在 agentcore/cdk/ 下搭建好了一个 CDK 应用。我们将在这个现有项目中添加 Lambda 触发器和 EventBridge 定时任务。EventBridge Scheduler 是一项 AWS 服务,可以按照 cron 计划触发事件。我们会让它每小时调用一次 Lambda,再由该 Lambda 调用执行框架。一个 agentcore deploy 负责执行框架,一个 cdk deploy 负责调度器。我喜欢将二者分开,因为相比调度基础设施,你会更频繁地迭代 AI 智能体配置。
mkdir -p agentcore/cdk/lambda/pulse-trigger
cd agentcore/cdk/lambda/pulse-trigger
npm init -y
npm install @aws-sdk/client-bedrock-agentcore @aws-sdk/client-secrets-manager
npm install -D typescript @types/node esbuild
cd ../../../..
创建 agentcore/cdk/lambda/pulse-trigger/index.ts:
import {
BedrockAgentCoreClient,
InvokeHarnessCommand,
} from "@aws-sdk/client-bedrock-agentcore";
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
interface TelegramSecret {
botToken: string;
defaultChatId: string;
}
const agentCore = new BedrockAgentCoreClient({ region: process.env.AWS_REGION });
const secrets = new SecretsManagerClient({ region: process.env.AWS_REGION });
// Cache the secret across warm invocations
let cachedSecret: { value: TelegramSecret; expiry: number } | null = null;
const SECRET_TTL_MS = 5 * 60 * 1000; // 5 minutes
async function getSecret(): Promise<TelegramSecret> {
if (cachedSecret && Date.now() < cachedSecret.expiry) {
return cachedSecret.value;
}
const secretArn = process.env.TELEGRAM_SECRET_ARN;
if (!secretArn) throw new Error("Missing TELEGRAM_SECRET_ARN env var");
const res = await secrets.send(
new GetSecretValueCommand({ SecretId: secretArn })
);
const value: TelegramSecret = JSON.parse(res.SecretString!);
cachedSecret = { value, expiry: Date.now() + SECRET_TTL_MS };
return value;
}
async function sendToTelegram(
botToken: string, chatId: string, text: string
) {
// Telegram's message limit is 4096 chars. Split if needed.
const chunks = text.match(/[\s\S]{1,4000}/g) || [];
for (const chunk of chunks) {
const res = await fetch(
`https://api.telegram.org/bot${botToken}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text: chunk,
parse_mode: "Markdown",
}),
}
);
if (!res.ok) {
console.error("Telegram send failed:", await res.text());
}
}
}
export const handler = async () => {
const hour = new Date().getUTCHours();
const sessionId = process.env.STABLE_SESSION_ID!;
const harnessArn = process.env.HARNESS_ARN!;
const isSummaryRun = hour % 6 === 0;
const prompt = isSummaryRun
? "Generate summary. Read all snapshots from the last 6 hours and write a trending stories digest."
: "Hourly run. Collect AI/ML stories from Hacker News and Reddit. Save the snapshot.";
const command = new InvokeHarnessCommand({ harnessArn, runtimeSessionId: sessionId, actorId: "scheduler", messages: [{ role: "user", content: [{ text: prompt }] }], });
const response = await agentCore.send(command);
// Collect the full response text from the stream let responseText = ""; let chunkCount = 0; if (response.stream) { for await (const event of response.stream) { chunkCount++; if ("contentBlockDelta" in event) { const delta = (event as any).contentBlockDelta?.delta; if (delta && "text" in delta) { responseText += delta.text; process.stdout.write(delta.text); } } } }
console.log(
\n[pulse-trigger] Stream complete — ${chunkCount} chunks
);
// On summary runs, send the digest to Telegram if (isSummaryRun && responseText) { const { botToken, defaultChatId } = await getSecret(); await sendToTelegram(botToken, defaultChatId, responseText); }
return { statusCode: 200, hour, type: isSummaryRun ? "summary" : "collection", digestSent: isSummaryRun, }; };
下面我们来拆解一下这里发生了什么。
Lambda 会传入一个稳定的会话 ID(来自环境变量),因此每小时的运行都会写入同一个 `/mnt/data`。它会从流中收集完整的响应文本,并将其记录到 CloudWatch。在执行汇总任务时,它会使用存储在 Secrets Manager 中的机器人令牌,将摘要发送到 Telegram。Secrets Manager 是一项 AWS 服务,用于存储 API 密钥和令牌等敏感值。你肯定不希望机器人令牌放在环境变量中,因为任何有权访问控制台的人都能看到它。`sendToTelegram` 函数通过分块发送文本来应对 Telegram 的 4096 字符限制。在早期调试阶段,CloudWatch 日志对我非常有帮助,因为那时 AI 智能体偶尔会陷入浏览器循环。
## 添加 Scheduler Stack
创建 `agentcore/cdk/lib/scheduler-stack.ts`。这是一个 CDK stack,基本上可以理解为一个部署单元。此文件中的所有内容都会作为一个 CloudFormation stack 一起部署。
import { Stack, StackProps, Duration, TimeZone, CfnOutput, } from "aws-cdk-lib"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import { Runtime } from "aws-cdk-lib/aws-lambda"; import { Secret } from "aws-cdk-lib/aws-secretsmanager"; import * as iam from "aws-cdk-lib/aws-iam"; import * as scheduler from "aws-cdk-lib/aws-scheduler"; import * as targets from "aws-cdk-lib/aws-scheduler-targets"; import { Construct } from "constructs"; import * as path from "path";
export interface PulseSchedulerStackProps extends StackProps { agentRuntimeArn: string; harnessArn: string; stableSessionId: string; telegramSecretArn: string; }
export class PulseSchedulerStack extends Stack { constructor( scope: Construct, id: string, props: PulseSchedulerStackProps ) { super(scope, id, props);
const telegramSecret = Secret.fromSecretCompleteArn( this, "TelegramSecret", props.telegramSecretArn );
const triggerFn = new NodejsFunction(this, "PulseTrigger", { runtime: Runtime.NODEJS_22_X, entry: path.join( __dirname, "../../lambda/pulse-trigger/index.ts" ), handler: "handler", timeout: Duration.minutes(15), memorySize: 256, environment: { HARNESS_ARN: props.harnessArn, STABLE_SESSION_ID: props.stableSessionId, TELEGRAM_SECRET_ARN: props.telegramSecretArn, }, bundling: { externalModules: [], }, });
triggerFn.addToRolePolicy( new iam.PolicyStatement({ actions: [ "bedrock-agentcore:InvokeHarness", "bedrock-agentcore:InvokeAgentRuntime", ], resources: [ props.harnessArn, props.agentRuntimeArn, ], }) );
// Read access to the Telegram bot token secret telegramSecret.grantRead(triggerFn);
new scheduler.Schedule(this, "HourlySchedule", { schedule: scheduler.ScheduleExpression.cron({ minute: "0", hour: "*", timeZone: TimeZone.of("UTC"), }), target: new targets.LambdaInvoke(triggerFn, {}), description: "The Pulse — hourly AI/ML news collection", });
new CfnOutput(this, "TriggerFunctionName", { value: triggerFn.functionName, }); } }
`telegramSecret.grantRead(triggerFn)` 这一行授予 Lambda 从 Secrets Manager 读取机器人令牌的权限。机器人令牌存储在那里,而不是放在环境变量中,因此它不会出现在 CloudFormation 输出或 Lambda 控制台里。
在 `agentcore/cdk/bin/cdk.ts` 中将其接入:
import { PulseSchedulerStack } from "../lib/scheduler-stack";
// ... existing AgentCore stack code ...
new PulseSchedulerStack(app, "ThePulseScheduler", { harnessArn: "REPLACE_WITH_YOUR_HARNESS_ARN", agentRuntimeArn: "REPLACE_WITH_YOUR_RUNTIME_ARN", stableSessionId: "REPLACE_WITH_YOUR_SESSION_ID", telegramSecretArn: "REPLACE_WITH_YOUR_SECRET_ARN", env: { account: targets[0].account, region: targets[0].region, }, });
我在这里使用占位符,是为了清楚地展示每个值应该填在哪里。你可以通过 `agentcore status --json` 获取 harness ARN 和 runtime ARN,并使用之前测试时所用的同一个会话 ID。Telegram secret ARN 可以从 Secrets Manager 获取(将机器人令牌和聊天 ID 存储为 JSON 对象,键名分别为 `botToken` 和 `defaultChatId`)。
不过,你不应该将 ARN 和 secret 硬编码到会提交至 git 的源文件中。实际仓库会从 `agentcore deploy` 创建的部署状态文件中自动解析 harness ARN 和 runtime ARN,并从环境变量中读取 Telegram secret ARN 和会话 ID。这样一来,任何敏感信息都不会进入版本控制。完整实现请查看仓库中的 `cdk.ts`。
cd agentcore/cdk && npm install esbuild --save-dev && cd ../..
agentcore deploy
cd agentcore/cdk && npm run build && npx cdk deploy ThePulseScheduler && cd ../..
直接测试 Lambda:
aws lambda invoke
--function-name $(aws cloudformat