写给想看懂一个 Agent 是怎么用代码跑起来的后端同学。
不讲架构图,不讲术语,就讲代码到底做了什么、为什么这么做。


Twitter 上有个女生给自己做了一个私人 AI 音乐电台,我把她分享的设计图喂给 Claude,Claude 直接写出来了。源码在这里:https://github.com/qwzhang01/claudio

本身是个 demo,谈不上成熟,但恰好适合拿来做学习材料——代码不多、结构清晰、每个关键设计点都有迹可循。读完这篇,你对"一个 Agent 到底长什么样"会有一个具体的参照系。

不到 1000 行有效代码,它踩中了当前 Agent 设计的 6 个主流套路。


先交代背景

这是一个 PWA(渐进式 Web 应用)——前端是 public/ 下的浏览器页面,负责展示 UI、放音乐、与后端实时通信;后端是 src/ 下的 Node.js 服务,负责理解你的话、调 AI、搜歌、存数据。

它依赖这几个外部接口才能跑起来:

外部接口 干什么 没它会怎样
Claude / OpenAI API 理解你的话、推荐歌、生成播报词 没配 Key 就走 mock 假数据
网易云音乐 API(本地子进程) 搜歌、拿播放链接、歌词 搜不到歌,但不崩
天气 API(和风天气 → OpenWeather) 知道当下晴雨冷暖,影响推荐 挂了用备用源 / mock
TTS 服务 把 DJ 播报词念出来 没声音,文字照常显示

一句话:前端负责"看和听",后端负责"想和找",外部 API 负责"脑子和曲库"。


文件结构

claudio/
├── src/
│   ├── server.js          ← 主服务器,启动一切
│   ├── config.js          ← 读 .env 的配置
│   ├── core/
│   │   ├── router.js      ← 入口:收到输入,决定走哪条路
│   │   ├── context.js     ← 拼提示词:把几块信息合成 system prompt
│   │   ├── llm-router.js  ← 选大脑:Claude 还是 OpenAI?
│   │   ├── claude.js      ← 调 Claude API + 解析输出
│   │   ├── openai.js      ← 调 OpenAI API + 解析输出
│   │   ├── scheduler.js   ← 定时触发:6个时间点自动推歌
│   │   └── state.js       ← SQLite:存对话、播放记录、计划、偏好
│   └── services/
│       ├── music.js       ← 封装网易云音乐 API
│       ├── weather.js     ← 天气服务(三级兜底)
│       └── tts.js         ← 文字转语音
├── prompts/
│   └── persona.md         ← DJ 人设 + 输出 JSON 格式约定
├── user/
│   ├── taste.md           ← 用户音乐品味
│   ├── routines.md        ← 用户日常作息
│   └── mood-rules.md      ← 用户情绪规则
└── public/                ← 前端静态页面

总共十几个文件,有效代码不到 1000 行。下面把每条主干讲清楚。


  1. 服务器启动:先拉起音乐服务

server.js 启动时顺序做三件事。

① 起一个网易云音乐 API 子进程

const proc = spawn('npx', ['NeteaseCloudMusicApi'], {
  env: { ...process.env, PORT: port },
  stdio: 'pipe',
});

spawn 起一个独立的 npx NeteaseCloudMusicApi 进程(默认跑在 3001 端口),然后轮询检查 http://localhost:3001/search 能不能返回 HTTP 响应——不管是 200 还是 400,只要不是"连不上"就算就绪。最多等 15 秒,超时不报错,服务器照常启动,只是音乐搜索可能用不了。

② 注册 9 个 REST 接口 + 1 个 WebSocket

常用的几个:

  • GET /api/now — 当前在播什么、听了多久
  • POST /api/chat — 和 AI DJ 对话(核心入口)
  • GET /api/search — 搜歌
  • GET /api/song/url/:id — 拿某首歌的播放链接
  • GET/PUT /api/taste — 读取 / 更新用户品味
  • POST /api/tts — 文字转语音
  • POST /api/transition — 两首歌之间的过渡播报

WebSocket 端点 /stream 干两件事:注册 scheduler 的广播监听(定时任务触发时主动推消息给前端);接收前端发来的 chat(聊天)和 play_event(播放记录)消息。

③ 启动定时任务

(async () => {
  await startNeteaseApi();       // 先等网易云 API 就绪
  app.listen(config.port, ...);  // 再启动 HTTP 服务
  scheduler.start();             // 最后启动定时任务
})();

顺序不能乱:定时任务触发后可能要搜歌,所以得先等音乐服务起来。


  1. 消息进来怎么走:先正则,再 AI

router.js 是整个 Agent 的入口,handle(input) 做两步分流。

第一步:用正则识别"明确的音乐指令"

_detectMusicIntent(input) {
  if (/搜索|搜|找/.test(input)) { ... return { type: 'search', keyword }; }
  if (/播放|放一首|来首|听一下/.test(input)) { ... return { type: 'play', keyword }; }
  if (/下一首|next|换一首|切歌/.test(lower)) { return { type: 'next' }; }
  if (/暂停|pause|停/.test(lower)) { return { type: 'pause' }; }
  return null;
}

命中了就直接走音乐服务,不调 AI。比如"播放周杰伦"被切成 play + 关键词"周杰伦",直接去搜歌播放。

好处很实际:确定性的事不花 AI 的钱,也不受模型状态影响,100% 可预测。

没命中(比如"来点适合下雨天的歌")才走 AI。

第二步:交给 AI 理解

async _handleWithClaude(input) {
  const { systemPrompt, messages } = await context.build(input);
  const response = await llm.compute(systemPrompt, messages);

  if (response.play && response.play.length > 0) {
    for (const songStr of response.play.slice(0, 5)) {
      const keyword = songStr.split(' - ')[0] || songStr;
      const results = await musicService.search(keyword, 1);
      if (results.length > 0) {
        const url = await musicService.getSongUrl(results[0].id);
        songsWithUrl.push({ ...results[0], url });
        state.addPlay(...);
      }
    }
    response.songs = songsWithUrl;
  }
  state.addMessage('assistant', JSON.stringify(response));
  return response;
}

AI 返回的 play 是字符串数组,形如 ["Clair de Lune - Debussy"]。代码取 - 前面的歌名去网易云搜,搜到后再拿播放链接,最后把带 URL 的歌挂到 response.songs 上。

主动推荐走同一条管道:定时任务触发的 proactiveRecommend(trigger) 和用户触发的 handle(input) 几乎一模一样,区别只在入口处多了一句 [系统触发: morning_wake] 提示 AI 这是主动推荐场景。后面的搜歌、拿链接逻辑完全共用。

过渡播报走特殊管道:handleTransition() 用专门的提示词,只要一段 30 字以内的衔接词,不要 JSON、不要推荐歌——因为它只需要 DJ 的一句话串场。


  1. 每次调 AI 前,提示词怎么拼

context.build() 把 6 块信息拼成完整的 system prompt:

  1. 1. 人设:读 prompts/persona.md,定义 DJ 是谁、说话什么风格、输出必须是什么 JSON 格式。

  2. 2. 用户资料:读 user/ 下的三个文件——taste.md(喜欢什么)、routines.md(几点起几点睡)、mood-rules.md(什么天气听什么)。改这几个文件不用动代码,直接编辑文本即可。

  3. 3. 环境:当前时间 + 天气(几点、周几、下雨还是晴、温度多少)。

  4. 4. 记忆:从数据库读最近 5 首播放记录 + 今日统计,让 AI 别老推重复的歌。

  5. 5. 用户输入:放在 messages 数组的最后一条。

  6. 6. 今日计划:从数据库读今天的计划安排。

最终拼出来的 system prompt 大概长这样:

[persona.md 内容]
---
[taste.md 内容]
---
[routines.md 内容]
---
[mood-rules.md 内容]
---
## 当前环境
- 时间: 2026-07-06 周六 13:24 (下午)
- 天气: Shanghai 多云, 22°C, 体感21°C
---
## 最近播放记录
- Clair de Lune - Debussy (2026-07-06 12:30:00)
---
## 今日计划
- [done] 07:00: 早间唤醒
- [pending] 18:00: 下班放松

关键点:每次调用都重新拼。时间变了、天气变了、你刚听了什么,prompt 就跟着变,AI 的推荐也就跟着变。这就是 Agent 里"把环境状态和记忆实时注入上下文"的基本套路。


  1. 怎么调 AI:换供应商只改配置

llm-router.js 只干一件事——根据配置选大脑:

function getBrain() {
  const protocol = (config.llmProtocol || 'anthropic').toLowerCase();
  return protocol === 'openai' ? openaiBrain : claudeBrain;
}
export async function compute(systemPrompt, messages) {
  const brain = getBrain();
  return brain.compute(systemPrompt, messages);
}

.env 里的 LLM_PROTOCOLopenai 用 OpenAI 适配器,其他一律 Claude。业务代码只调 llm.compute(),不关心底层是谁。

解析做了三层兜底(因为 AI 不一定老老实实按格式返回):

  1. 1. 用正则 //{[/s/S]*/}/ 把 JSON 部分抠出来,过滤掉 AI 在前后加的废话;

  2. 2. JSON.parse() 包在 try/catch 里,格式错了也不崩;

  3. 3. 每个字段都给默认值:say: parsed.say || ''play: Array.isArray(parsed.play) ? parsed.play : []

全失败了,就把整段文本当 say 返回,不报错。

openai.js 和 claude.js 接口完全一样,内部差异只是 OpenAI 把 system 放进 messages 数组第一条,而 Claude 的 system 是单独参数。

mock 模式:没配 API Key 时,按当前时间段返回不同的假数据——早上给唤醒歌单、工作时段给专注歌单、晚上给放松歌单、深夜给助眠歌单。不联网也能跑起来看效果。


  1. 定时推歌:不用人说话也能播

scheduler.jsnode-cron 挂了 6 个时间点:

cron.schedule('0 7 * * *',    () => this._trigger('morning_wake'));
cron.schedule('0 9 * * 1-5',  () => this._trigger('work_start'));
cron.schedule('0 12 * * *',   () => this._trigger('lunch_break'));
cron.schedule('0 14 * * 1-5', () => this._trigger('afternoon_start'));
cron.schedule('0 18 * * 1-5', () => this._trigger('work_end'));
cron.schedule('0 22 * * *',   () => this._trigger('sleep_prep'));

1-5 是周一到周五,* 是每天。触发后调 router.proactiveRecommend(trigger)——和用户发消息走的是同一条管道,只是入口多了一句"系统触发"的提示。

怎么推给前端? scheduler 维护一个 listeners 数组,WebSocket 连上时注册监听、断开时移除。触发时 _broadcast() 把消息发给所有连着的客户端。


  1. 数据怎么存

state.js 用 SQLite(一个本地文件 state.db)存 4 张表:

用途 常用方法
messages 对话历史 addMessage(role, content) / getRecentMessages(10)
plays 播放记录 addPlay(...) / getRecentPlays(5) / getTodayPlays()
plans 每日计划 setPlan(...) / getTodayPlan()
prefs 键值对偏好 setPref / getPref,值支持 JSON

/api/now/api/history 等接口直接读这些表返回给前端;router.handle() 执行完会写对话和播放记录。


  1. 外部服务挂了怎么办

每个外部依赖都有降级,任何一个挂了程序都还能跑:

  • AI 没 Key → 走 mock 假数据
  • 天气 API 挂了 → 和风天气 → OpenWeather → mock,三级兜底;且 30 分钟内不重复请求
  • 网易云 API 挂了 → 搜歌返回空结果,不崩
  • AI 返回格式不对 → 三层兜底解析(见第 4 节)

  1. persona.md:AI 的"性格"从哪来

prompts/persona.md 是 AI 行为的源头,定义了:

  • 身份:私人音乐电台主持人
  • 性格:温暖不啰嗦,像电台 DJ
  • 核心能力:按时间/天气/状态推荐歌、做过渡播报、记住喜好、主动播报
  • 输出约定:必须是 JSON,包含 say(播报词)、play(推荐歌列表)、reason(理由)、seque(下一步动作)

这就是为什么解析器敢期望 AI 返回 JSON,也是为什么必须做容错——AI 不一定每次都听话。


把流程串一遍

你发"来点适合下雨天的音乐",实际发生了什么:

POST /api/chat  →  message = "来点适合下雨天的音乐"
         ↓
   router.js:正则没命中 → 走 AI
         ↓
   context.js:拼6块信息(人设+用户资料+时间天气+播放记录+用户输入+今日计划)
         ↓
   llm-router.js:选 Claude → claude.js 调 API
         ↓
   解析出 {say, play: ["Riders on the Storm - The Doors", ...], reason}
         ↓
   遍历 play → musicService.search() → getSongUrl()
         ↓
   返回 {say, songs(带URL), reason}  →  存库(addMessage + addPlay)

定时触发(比如每天 07:00)流程完全一样,只是入口从你的消息变成了 scheduler._trigger('morning_wake')


从 Agent 设计角度看,它踩中了哪些主流思路

它本身是个 demo,谈不上最佳实践,但回过头看,它无意中把当前 Agent 设计里几个被反复强调的点都做出来了:

  1. 规则路由 + LLM 兜底(确定性优先)

主流做法不是"什么都丢给大模型",而是先判断这事靠规则能不能稳稳搞定。router.js 先用正则识别"播放/暂停/下一首"这种确定性指令,命中就直接走音乐服务;只有识别不了的才交给模型。好处:省 token、响应快、行为可预测。

  1. 每次调用都动态拼装上下文(Context 即记忆)

Agent 不是靠一次 prompt 就能聪明的。context.js 每次调模型前都把"人设 + 用户资料 + 当前时间/天气 + 最近播放记录 + 今天计划"重新拼一遍。时间变了、天气变了、你刚听了什么,prompt 就跟着变,模型推荐也就跟着变。

  1. 模型通过适配层接入(供应商可替换)

llm-router.js 把 Claude 和 OpenAI 包在同一套 compute() 接口后面,业务代码不关心底下是谁。换脑子只改配置,不碰业务——对想做多模型切换的 Agent 来说,这是标准结构。

  1. 结构化输出 + 不信任模型的容错解析

Agent 最忌讳把模型当函数用、假设它每次都返回规整 JSON。三层兜底:正则抠 JSON → try/catch 解析 → 字段默认值 → 实在不行整段当文本返回。永远假设模型可能胡说,做好退化。

  1. 主动触发(Proactive),不只是被动应答

scheduler.js 在固定时间点主动推歌,走的和用户触发的同一条管道。这就是把 Agent 从"问答机"往"能自主行动的助手"推了一步。

  1. 每个外部依赖都有降级

AI 没 Key → mock;天气挂了 → 备用源 / mock;音乐 API 起不来 → 搜索返回空、不崩。把 Agent 当"生产系统"而不是"玩具脚本"对待的基础。


一个 demo 能把这些点都自然长出来,恰恰说明这些是 Agent 设计的"常识级"结构,而不是高深技巧。把它读通,你对"一个 Agent 大概长什么样"就有了一个具体的参照系。

源码在这里,可以直接跑起来看:https://github.com/qwzhang01/claudio


💡 这部分建议收藏,下次搭 Agent 的时候对照着看。


收藏速查

模块 核心职责 关键文件
分流路由 正则命中走规则,没命中走 AI router.js
上下文拼装 每次调 AI 前动态构建 prompt context.js
模型适配层 Claude / OpenAI 同接口,配置切换 llm-router.js
容错解析 三层兜底,模型返回格式不对不崩 claude.js / openai.js
定时触发 6 个时间点主动推歌,同一条管道 scheduler.js
状态持久化 SQLite 存对话、播放、计划、偏好 state.js
外部降级 每个依赖都有 fallback weather.js / config.js

这里给大家精心整理了一份全面的AI大模型学习资源包括:AI大模型全套学习路线图(从入门到实战)、精品AI大模型学习书籍手册、视频教程、实战学习、面试题等,资料免费分享

👇👇扫码免费领取全部内容👇👇
在这里插入图片描述

1. 成长路线图&学习规划

要学习一门新的技术,作为新手一定要先学习成长路线图方向不对,努力白费

这里,我们为新手和想要进一步提升的专业人士准备了一份详细的学习成长路线图和规划。可以说是最科学最系统的学习成长路线。
在这里插入图片描述

2. 大模型经典PDF书籍

书籍和学习文档资料是学习大模型过程中必不可少的,我们精选了一系列深入探讨大模型技术的书籍和学习文档,它们由领域内的顶尖专家撰写,内容全面、深入、详尽,为你学习大模型提供坚实的理论基础(书籍含电子版PDF)

在这里插入图片描述

3. 大模型视频教程

对于很多自学或者没有基础的同学来说,书籍这些纯文字类的学习教材会觉得比较晦涩难以理解,因此,我们提供了丰富的大模型视频教程,以动态、形象的方式展示技术概念,帮助你更快、更轻松地掌握核心知识

在这里插入图片描述

4. 2026行业报告

行业分析主要包括对不同行业的现状、趋势、问题、机会等进行系统地调研和评估,以了解哪些行业更适合引入大模型的技术和应用,以及在哪些方面可以发挥大模型的优势。

5. 大模型项目实战

学以致用 ,当你的理论知识积累到一定程度,就需要通过项目实战,在实际操作中检验和巩固你所学到的知识,同时为你找工作和职业发展打下坚实的基础。

在这里插入图片描述

6. 大模型面试题

面试不仅是技术的较量,更需要充分的准备。

在你已经掌握了大模型技术之后,就需要开始准备面试,我们将提供精心整理的大模型面试题库,涵盖当前面试中可能遇到的各种技术问题,让你在面试中游刃有余。

在这里插入图片描述

7. 资料领取:全套内容免费抱走,学 AI 不用再找第二份

不管你是 0 基础想入门 AI 大模型,还是有基础想冲刺大厂、了解行业趋势,这份资料都能满足你!
现在只需按照提示操作,就能免费领取:

👇👇扫码免费领取全部内容👇👇
在这里插入图片描述

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐