# 从零构建社媒内容工作台（Easel 系）· 开发全流程教程

> **这份教程教你什么**：不是再写一个 ReAct 循环，而是造一个**把别人的 Agent CLI 当引擎、专门产出真实社媒内容并可以真发的宿主**——Easel（`ZJU-REAL/Easel`，Apache-2.0，commit `765f5a6`）那种。
> **最反直觉的一点**：这条路线里，**你一行 Agent 主循环都不写**。你要写的是「插座 + 剧本 + 项目目录 + 出站闸门」。
> **怎么教**：跟着一句真实需求从头走到尾——**「用我的科技数码账号，做一条小红书笔记并发出去」**。每撞一堵墙补一个零件。
> **对照源码**：每个零件给出 Easel 的 `文件:行号`。配套阅读《[Easel 源码分析](./项目分析/Easel-源码分析.html)》和交互实验台 [Easel-解析.html](./Easel-解析.html)。
> **前置**：知道「Agent = 模型说话 → 软件替它动手 → 结果喂回模型」就够了。你不需要会剪视频，也不需要会写 OpenClaw。

---

**目录**

- [先看终点：一个社媒工作台长什么样](#end)
- [第 0 步 · 场景登场：一句话里藏的十二堵墙](#s0)
- [第 1 步 · 决定不写 Agent：把 OpenClaw 当引擎](#s1)
- [第 2 步 · 三入口共用一份画像前缀](#s2)
- [第 3 步 · 四层 prompt 栈，常驻层不写 skill 名](#s3)
- [第 4 步 · sync：workspace 是剧本，项目根是工地](#s4)
- [第 5 步 · SKILL.md 契约：200 行 SOP + 脚本不进 prompt](#s5)
- [第 6 步 · outputs 门禁：内容是项目](#s6)
- [第 7 步 · manifest：薄索引，不口传全文](#s7)
- [第 8 步 · TURN_REMINDER：近因对抗指令衰减](#s8)
- [第 9 步 · content_guard：真发前的 fail-closed](#s9)
- [第 10 步 · persona_gate：人设只提醒、永不阻断](#s10)
- [第 11 步 · SSE：断线不杀，结果落盘](#s11)
- [第 12 步 · 双锁 + uuid5：会话不串味、隔天不丢](#s12)
- [第 13 步 · session_heal：上游把 thinking 存丢了签名](#s13)
- [第 14 步 · 登录与 `--exec`：进入真实平台](#s14)
- [第 15 步 · doctor / ping：环境做成产品命令](#s15)
- [🎬 完整回放：这一句话到底跑了什么](#replay)
- [附录 A · 验收断言](#a1)
- [附录 B · 十二个最容易翻的车](#a2)
- [附录 C · 源码对照索引](#a3)

---

<h2 id="end">先看终点：一个社媒工作台长什么样</h2>

编程 Agent 和社媒工作台的分界，不在模型，而在这六件事：

1. **它自己不推理**——推理是机器上那个 `openclaw --profile easel` 在干。
2. **产物是给人看、给人发的**，不是给编译器看的。
3. **一个选题必须是一个文件夹**，后续改稿、重试、发布不能散在聊天里。
4. **账号有人设**，而且多个账号会并行开会话——全局 USER.md 会互踩。
5. **发出去的字删不掉**，所以密钥和内部域名必须确定性扫描。
6. **制作可能跑两小时**，浏览器 SSE 会被代理掐断——引擎不能跟着连接死。

```mermaid
flowchart TB
    subgraph SEE["① 创作者看见的"]
        CHAT["对话"]
        LIB["内容库 outputs/"]
        ACC["账号登录"]
        PUB["发布中心"]
    end
    subgraph HOST["② 你要写的宿主"]
        PREFIX["画像前缀 + TURN_REMINDER"]
        SSE["supervisor / forward 拆开"]
        LOCK["asyncio 锁 + flock"]
        GUARD["content_guard + persona_gate"]
        PATH["output_paths 门禁"]
    end
    subgraph PLAY["③ 剧本（文件，可版本化）"]
        SOUL["SOUL.md"]
        AG["AGENTS.md"]
        SK["skills/openclaw/*"]
        PF["profiles/六维 md"]
    end
    subgraph ENG["④ 引擎（不是你的代码）"]
        OC["openclaw CLI + gateway :18789"]
    end
    CHAT --> SSE
    SSE --> PREFIX
    PREFIX --> OC
    OC --> PLAY
    OC -->|cd 项目根跑脚本| PATH
    PATH --> LIB
    GUARD --> PUB
    ACC --> PUB
```

对照 Easel：整合包只有 768 行，`web/app.py` 2509 行，技能 112 个。你要造的 minieasel 不必 112 个 skill，但**这六件事一件都不能少**，否则「能聊」和「能发」会在第一周就分家。

---

<h2 id="s0">第 0 步 · 场景登场：一句话里藏的十二堵墙</h2>

用户说：

> 用我的科技数码账号，做一条小红书笔记并发出去。主题是「USB4 硬盘盒踩坑」。

这句话看起来像一句 prompt，拆开是十二个产品问题：

| # | 墙 | 不补零件时会发生什么 |
|---|---|---|
| 1 | 谁来推理 | 你开始手写 while 循环，三个月还在调工具解析 |
| 2 | 哪个账号 | 全局 USER.md，两个标签页互踩人设 |
| 3 | 笔记长什么样 | 模型写一篇公众号，发到小红书没人看 |
| 4 | 卡片怎么渲染 | 模型用 emoji 拼图，看起来像 PPT |
| 5 | 文件放哪 | `output.png` 扔在仓库根，下周找不到 |
| 6 | 策划结论怎么给制作 | 模型在对话里复述三千字，token 炸、还丢 |
| 7 | 长对话后不做 skill | 「凭记忆裸做」，脚本门禁被绕过 |
| 8 | 文案里出现 API Key | 发出去，删不掉 |
| 9 | 人设不太像 | 该拦还是该问？拦了创作者会骂 |
| 10 | 生成要 40 分钟 | SSE 被掐，前端以为死了，后端也杀了 |
| 11 | 两个窗口同一会话 | OpenClaw takeover，答到冒号停住 |
| 12 | 真发小红书 | 风控、短信墙、Playwright 登录态 |

后面每一步拆一堵墙。

---

<h2 id="s1">第 1 步 · 决定不写 Agent：把 OpenClaw 当引擎</h2>

**为什么**：循环、工具、会话、模型供应商，OpenClaw 已经有了。你的差异化是社媒 SOP 和真发。再写一个循环，是在和上游抢同一份工作。

**最小实现**：

```python
cmd = [
    "openclaw", "--profile", "easel",
    "agent", "--agent", "main",
    "--timeout", str(timeout),
    "--message", message,
]
subprocess.run(cmd, cwd=PROJECT_ROOT, env=proxy_env)
```

对照：`easel/commands/skill.py:91-106`、`easel/cli.py:103-119`。profile 名写死，和用户其它 OpenClaw 工作区隔离。

**验收**：`which openclaw` 成功；`easel ping` 能让 agent 回 PONG（`easel/commands/ping.py:62-70`）。

**真实 Easel**：gateway 监听 `18789`，doctor 打 healthz（`easel/commands/doctor.py:75-81`）。

---

<h2 id="s2">第 2 步 · 三入口共用一份画像前缀</h2>

**为什么**：CLI chat、CLI skill、Web 对话如果各写一份「当前用户是谁」，第三天就会出现「Web 有画像、CLI 没有」。更糟的是全局 USER.md：**并发会话写同一文件**。

**被否方案**：sync 时生成 USER.md。Easel 注释写明这是历史方案（`easel/persona.py:1-6`）。

**最小实现**：

```python
def persona_prefix(name: str | None) -> str:
    if name and profile_exists(name):
        return (
            f"我当前使用的画像是「{name}」。"
            f"本会话的账号长期记忆仅使用 profiles/{name}/memory.md，"
            "不要使用工作区全局 MEMORY.md 作为账号记忆。"
        )
    return ""
```

对照：`easel/persona.py:58-69`。六维文件顺序：identity / style / audience / platforms / preferences / memory（`:16-20`）。注入的是**指针**，不是把六份 md 全文塞进每条消息。

CLI chat 把前缀当**初始消息**（`easel/cli.py:112-117`）；Web 每轮都带（`web/app.py:798-805`）。超长会话被压缩后 CLI 可能丢画像——注释承认这是已知取舍（`easel/cli.py:112-114`）。

**验收**：两个并发请求分别带画像 A / B，workspace 里没有 USER.md，全局 MEMORY.md 为空（`openclaw/sync.sh:97-103`）。

---

<h2 id="s3">第 3 步 · 四层 prompt 栈，常驻层不写 skill 名</h2>

**为什么**：112 个 skill 名写进 SOUL.md，下架一个平台你就要改人格文件。常驻层只写类目：「能做发现/策划/制作/发布/归因」。具体名字留给被触发的 SKILL.md。

对照：`docs/prompt-stack.md` 全文；SOUL.md 自己说「先去技能库找」（`openclaw/workspace/SOUL.md:18`）；AGENTS.md 才写路由规则（`openclaw/workspace/AGENTS.md:7`）。

**最小实现**（四个文件，不要再多）：

| 层 | 文件 | 只管 |
|---|---|---|
| 1 | SOUL.md | 语气、能力类目、对外不自曝 |
| 2 | AGENTS.md | 先路由、先 cd 项目根、先查再问、真产物才算完、出站安全 |
| 3 | CONTEXT.md | 项目绝对路径（生成，不手写） |
| 4 | SKILL.md | 被触发才加载 |

**验收**：SOUL.md 里 grep 不到 `xhs-note-creator` 这种具体名字。

---

<h2 id="s4">第 4 步 · sync：workspace 是剧本，项目根是工地</h2>

**为什么**：OpenClaw 的 cwd 是 `~/.openclaw/workspace-easel/`。如果你让它在那儿跑 `python skills/...`，缺 `.env`、路径少一层、产物写到家目录。

**最小实现**（对照 `openclaw/sync.sh`）：

1. 把 SKILL 和 shared 复制进 workspace
2. 把项目根绝对路径**追加进 AGENTS.md**（`:77-93`）
3. `ln -s` profiles、outputs（先 rm 再 ln，防循环，`:107`）
4. 清空 MEMORY.md，删除 USER.md
5. 进程环境 `EASEL_ROOT=项目根`（`manifest.py:58-61` 解释了为什么 `__file__` 上溯会错）

AGENTS.md 写死：禁止从 workspace 的 shared 副本跑项目脚本（`openclaw/workspace/AGENTS.md:8-9`）。

**验收**：Agent 第一个脚本前的 `pwd` 是项目根；`test -f .env && test -d skills/shared/scripts` 为真。

---

<h2 id="s5">第 5 步 · SKILL.md 契约：200 行 SOP + 脚本不进 prompt</h2>

**为什么**：把 ffmpeg 命令写进系统提示又贵又易过时。SOP 写「怎么做」，像素操作放 `scripts/`，领域知识放 `references/`。

**最小实现**：一个 skill 目录：

```yaml
---
name: xhs-note-creator
description: >
  小红书内容总入口：……当用户说「做小红书笔记」时使用。
  仅渲染卡片用 card-xiaohongshu；其它平台用 social-content。
layer: produce
---
```

`description` 必须包含**触发说法**和**相邻边界**——这就是全部路由器（`docs/SKILL-SPEC.md:73-76`）。没有 Python `if intent == ...`。

**验收**：`python scripts/validate_skills.py` 通过；SKILL.md < 200 行。

真实 Easel 还有 `validate_skill_commands.py`：把 SKILL 正文里的 python 命令和脚本 argparse 对照，防文档漂移。

---

<h2 id="s6">第 6 步 · outputs 门禁：内容是项目</h2>

**为什么**：模型喜欢写 `outputs/test/out.png`。下周你有 40 个 test。

**最小实现**：所有写盘脚本先过 `validate_output_path`（`skills/shared/scripts/output_paths.py:41-75`）：

- 必须在 `outputs/` 下
- 内容路径至少 `outputs/<主题>/<文件>`
- 主题不能是泛名集合里的词
- `_` 开头是系统目录，必须 `allow_system=True` 且在白名单

成品放项目根，中间件进 `assets/`，元数据只有 `.easel.json`。

**验收**：试图写入 `outputs/xhs/a.png` 抛 `OutputPathError`（泛名 `xhs` 在 `GENERIC_PROJECT_NAMES`，`:25-29`）。

---

<h2 id="s7">第 7 步 · manifest：薄索引，不口传全文</h2>

**为什么**：跨「策划 → 制作 → 发布」如果靠模型在对话里复述脚本全文，又贵又丢。

**最小实现**：`manifest.py record --topic USB4硬盘盒踩坑 --layer produce --skill xhs-note-creator --outputs card_1.png,note.md --summary "5 张卡，钩子是接口兼容"`。

下游 `latest --layer produce` 只拿到路径 + 一句结论，再去读文件（`docs/SKILL-SPEC.md:157-159`）。失败也 ` --status failed`，才能断点续跑。

**验收**：`.easel.json` 的 `steps[].outputs` 是相对路径，不内嵌文案正文。

---

<h2 id="s8">第 8 步 · TURN_REMINDER：近因对抗指令衰减</h2>

**为什么**：第 1 轮模型会去读 SKILL；第 12 轮用户说「标题再改改」，模型凭记忆直接改，绕过 card-design 和去 AI 化。

**最小实现**：只在**对话入口**把一小段内部提醒拼在用户消息**末尾**（`easel/persona.py:72-99`）。单跑 skill 不加。前端只显示用户原文。

**验收**：抓一条 Web 发给 openclaw 的 `--message`，末尾能看到「本轮动手前先查技能库」，且这段不出现在聊天气泡里。

---

<h2 id="s9">第 9 步 · content_guard：真发前的 fail-closed</h2>

**为什么**：提示词写「不要输出 API Key」挡不住模型把报错、命令输出粘进文案。发出去是不可逆的。

**最小实现**：发布脚本 `--exec` 前调用 `guard_or_die(text)`。

- BLOCK（密钥、内部域名、代理 IP、内部路径、env 名、`.env` 真值）→ **exit 7**
- WARN（「由 AI 生成」、模型名）→ 打印提醒，放行

对照：`content_guard.py:11-15, 97-105`。双路扫描：正则 + `.env` 字面值（`:141-176`）。报告里命中片段打码。

**验收**：文案含 `sk-` 开头密钥，真发退出码为 7；文案含「Claude」的论文解读，退出码 0。

---

<h2 id="s10">第 10 步 · persona_gate：人设只提醒、永不阻断</h2>

**为什么**：密钥泄露是事故；「这条不太像数码博主」是创作判断。创作者点了发布，你再用分数拦住，产品就变监工。

**最小实现**：LLM 打分 → `persona_gate.py check --score N` → 永远 `publish_allowed: true`、退出码 0（`persona_gate.py:10-11, 42-54`）。低于 80 只是 warn，展示偏离点。AGENTS.md 规定用户已明确要发就继续（`openclaw/workspace/AGENTS.md:78`）。

**验收**：score=12 仍然 exit 0；manifest 里能 `record` 到这次 warn。

---

<h2 id="s11">第 11 步 · SSE：断线不杀，结果落盘</h2>

**为什么**：制作层超时 7200 秒（`easel/timeouts.py:11`）。浏览器和反向代理活不了那么久。如果 SSE 断开就 kill 子进程，视频永远做不完。

**最小实现**（对照 `web/app.py:1024-1401`）：

1. `asyncio.create_task(supervisor())`，客户端生成器只 `forward` 队列
2. 子进程环境打开 `OPENCLAW_RAW_STREAM`，tail 每轮 jsonl 推 token
3. `_save_turn` 原子写 `outputs/_sessions/<sk>.json`
4. 断开不 `terminate`；只有 `/api/chat/stop` 杀进程
5. 前端断线后轮询 `/api/chat/last`

**验收**：生成中关掉标签页，进程仍在；重开页面能取回完整回答。

---

<h2 id="s12">第 12 步 · 双锁 + uuid5：会话不串味、隔天不丢</h2>

**墙 11**：两个窗口同一 session → `EmbeddedAttemptSessionTakeoverError`，答到冒号停住（`web/app.py:808-811`）。

**最小实现**：

- 进程内 `asyncio.Lock` 按 session-key
- 跨进程 `fcntl.flock`（`web/app.py:841-896`）
- 前端 BroadcastChannel 探测占用（`App.tsx:55-125`）

**墙「隔天忘了」**：OpenClaw `--session-key` 绑定约 24h 过期（`web/app.py:823-827`）。

**最小实现**：`uuid.uuid5(固定命名空间, web_session_id)` 钉死 `--session-id`（`:828-833`）。

**验收**：两个标签撞同一会话，第二个看到排队/请换窗口，而不是 rc=1；隔 25 小时同一 sessionId 仍指向同一 jsonl。

---

<h2 id="s13">第 13 步 · session_heal：上游把 thinking 存丢了签名</h2>

**为什么**：这不是你的 bug，但用户看见的是「Session history or replay state is invalid」。OpenClaw 存 thinking 块时丢了 signature，Bedrock 回放校验失败（`scripts/session_heal.py:3-6`）。

**最小实现**：每轮 spawn 前删 `thinking` / `redacted_thinking` 块和空消息；默认 thinking 档位 `low`（`web/app.py:51-71`）。失败不抛，best-effort。

**验收**：含无签名 thinking 的 jsonl 经 sanitize 后，下一轮不再 400。

---

<h2 id="s14">第 14 步 · 登录与 `--exec`：进入真实平台</h2>

**为什么**：只给文案，创作者还要复制到创作者中心——工作台就断在最后一公里。Easel 选择 Playwright / biliup 真发，并在 README 警告小红书风控。

**最小实现**：

1. 按平台保存浏览器 profile（`~/.easel-browser-profiles`）
2. 发布 API 二次确认后拼 `--exec`（`web/app.py:1996-2053`）
3. 抖音短信墙：异步 + 状态文件 + 前端输入验证码（`:2040-2045`）
4. 发布前必须过 content_guard；人设只提醒
5. 图/视频互斥、部分平台必须带媒体——用代码硬约束，不靠模型记

**验收**：dry-run 列出将发内容且不触网；`--exec` 在 BLOCK 命中时 exit 7。

---

<h2 id="s15">第 15 步 · doctor / ping：环境做成产品命令</h2>

**为什么**：依赖链是 Python + Node 22.19 + FFmpeg + Playwright + 前端 dist + gateway + skills sync + API Key。任何一环缺失，用户都只会说「不能用」。

**最小实现**：`doctor` 静态打勾（`easel/commands/doctor.py:143-207`），`ping` 真的让 agent 说话（`ping.py`）。Key 检查拒绝 `REPLACE_ME` 占位符。

**验收**：拔掉 ffmpeg，doctor 红；配好后 ping 两步全绿。

---

<h2 id="replay">🎬 完整回放：这一句话到底跑了什么</h2>

用户在 Web 选画像「科技数码达人」，输入「做一条小红书：USB4 硬盘盒踩坑，做完直接发」。

1. 前端 `streamChat`，`turnId` 写入 sessionStorage（`App.tsx:197-198`）。
2. `_chat_message` = 画像前缀 + 用户原文 + 附件清单（无）+ TURN_REMINDER（`web/app.py:798-805`）。
3. supervisor 先 `_save_turn(running)`，再 `_heal_openclaw_session`，再抢 asyncio 锁 + flock。
4. `openclaw --profile easel agent --session-id <uuid5> --thinking low --timeout 7200`。
5. 模型读 SOUL / AGENTS / CONTEXT；TURN_REMINDER 要求先查技能库 → 命中 `xhs-note-creator`（produce）和后续 `skill-xhs-publisher`（publish）。跨两层，先短 Plan。
6. `cd` 到项目根。凝练 `easel-profiles/科技数码达人/` 的 identity/style/audience/preferences/memory，**不读** platforms.md（制作层规矩）。
7. 按 SKILL 问清形态（默认图文）、写长文、去 AI 化、走 card-design 渲染卡片到 `outputs/USB4硬盘盒踩坑/`。`output_paths.py` 拒绝任何泛名。
8. `manifest.py record --layer produce ...`；`meta --kind xhs-note --status ready`。
9. 发布前 `skill-persona-check` + `persona_gate`（warn 也继续）；`content_guard` 扫描标题/正文/标签。
10. `xhs_publish.py publish --images ... --exec`。成功则 manifest `published`，日历/publish-log 留痕。
11. raw 流 `assistant_message_end` → `_save_turn(done)` → SSE `done`。若代理早把 SSE 掐了，前端 `/api/chat/last` 仍能取回。
12. 内容库刷新，读 `.easel.json` 展示头，封面是第一张卡。

中间任何一层失败：manifest 记 `failed`，中途文件留在 `assets/`，**不以空壳当交付**（`openclaw/workspace/AGENTS.md:12, 62`）。

---

<h2 id="a1">附录 A · 验收断言</h2>

做完对应步骤，应能勾选：

- [ ] `openclaw --profile easel` 能跑，gateway `:18789/healthz` 200
- [ ] 三入口画像都走 `persona_prefix`，无 USER.md
- [ ] SOUL.md 不含具体 skill 名
- [ ] 脚本 cwd 是项目根，`EASEL_ROOT` 已设
- [ ] SKILL frontmatter 只有 name/description/layer
- [ ] `outputs/test/a.png` 被门禁拒绝
- [ ] manifest `summary` 一行，正文在文件里
- [ ] Web 对话消息末尾有 TURN_REMINDER，气泡没有
- [ ] 含 `sk-` 的待发文案 exit 7
- [ ] persona score=10 仍允许发布
- [ ] 关掉标签页，openclaw 进程仍在，last turn 可取回
- [ ] 两窗口同会话：第二个排队或被拒，不崩溃
- [ ] 同一 web id 隔日后 `--session-id` 不变
- [ ] 无签名 thinking 的历史被 sanitize
- [ ] doctor 能指出缺 ffmpeg / 缺 dist / 缺 key

---

<h2 id="a2">附录 B · 十二个最容易翻的车</h2>

1. **手写循环。** 你会把时间花在工具解析，而不是 SOP 和闸门。
2. **全局 USER.md。** 多账号第一天就互踩。
3. **在 workspace 里跑脚本。** `__file__` 少一层，产物写到 `~/.openclaw`。
4. **相信 `openclaw.json5`。** 它自己说从不被 setup 应用（`openclaw/openclaw.json5:2-5`）。
5. **成品当聊天附件。** 无法复盘、无法发布中心选媒体。
6. **manifest 复制全文。** 索引变脏，下游仍去「凝练」。
7. **只靠系统提示防泄密。** 真发路径必须确定性扫描。
8. **人设分数阻断发布。** 创作者会关产品。
9. **SSE 断开就杀进程。** 制作层任务全部失败。
10. **只加 asyncio 锁。** 第二个浏览器标签照样 takeover。
11. **只传 `--session-key`。** 隔天历史蒸发。
12. **小红书默认自动发。** 账号风控。默认预览，人点确认再 `--exec`。

---

<h2 id="a3">附录 C · 源码对照索引</h2>

| 步骤 | 主要文件 |
|---|---|
| 1 引擎插座 | `easel/cli.py`、`easel/commands/skill.py` |
| 2 画像 | `easel/persona.py` |
| 3 prompt 栈 | `docs/prompt-stack.md`、`openclaw/workspace/*.md` |
| 4 sync | `openclaw/sync.sh` |
| 5 SKILL | `docs/SKILL-SPEC.md` |
| 6 路径 | `skills/shared/scripts/output_paths.py` |
| 7 manifest | `skills/shared/scripts/manifest.py` |
| 8 提醒 | `easel/persona.py:72-99` |
| 9 出站 | `skills/shared/scripts/content_guard.py` |
| 10 人设 | `skills/shared/scripts/persona_gate.py` |
| 11–13 对话 | `web/app.py:808-1415`、`scripts/session_heal.py` |
| 14 发布 | `web/app.py:1996-2068` |
| 15 环境 | `easel/commands/doctor.py`、`ping.py` |
| 超时 | `easel/timeouts.py` |
| 前端会话 | `web/frontend/src/App.tsx` |
