我做了一个让 AI 帮我写文章 + 自动发到知乎掘金的系统,这是它的全部架构
这篇文章本身就是用这个系统发布的。读完你就知道我说的是真的。
起因:我受够了重复劳动
作为一个全栈开发者,过去半年我有三件事一直在消耗时间:
- 写技术博客——明明思路清晰,但落到 Markdown + 排版 + 配图 + 同步多平台,一篇文章要折腾两个小时
- 给团队接 AI 能力——每个项目都重复"接 LLM、做 Agent、加技能、对接钉钉/飞书"这套
- 生成 PPT、视频、文档——客户要,但用 Office/PR 一坨一坨地手搓让人崩溃
某天我盯着这堆碎片化工作,问自己一个问题:为什么不做一个统一的 AI Agent 平台,把这些事都自动化掉?
于是有了 AAAS(AI Agent as a Service)。
3 个月,3 万行 Go + 2 万行 React + 一堆痛苦的踩坑后,它现在能做这些事:
- 一句话生成一篇技术文章并自动发到知乎+掘金(就像这篇)
- 一句话生成一个 AI 视频(自带 TTS 配音 + 素材编辑)
- 一句话生成一份 PPT(20+ 模板,真实可下载的 .pptx)
- 接入钉钉/飞书/企微机器人,直接对话调用 Agent
- 一个统一的 LLM 网关,自动负载均衡 GPT/Claude/Qwen/DeepSeek
- 完整的技能市场,20+ 内置技能,装一个就能用
下面是它的完整架构和几个最痛的坑。希望对正在做类似系统的你有用。
一、整体架构图
┌──────────────────────────────────────────────────────────┐
│ 用户入口 │
│ Web 管理后台 │ Electron 客户端 │ 钉钉 │ 飞书 │ 企微 │
└────────────┬─────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ API 网关层 (Go + Gin + JWT/bcrypt) │
│ ─ 认证中间件(双模式: API Key + JWT) │
│ ─ 限流 / 计费 / 配额 │
│ ─ 流式代理(SSE / WebSocket) │
└────────────┬─────────────────────────────────────────────┘
│
┌─────────┼─────────┬──────────┬───────────┐
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│Agent │ │ LLM │ │ Skill │ │ Video │ │ Article │
│引擎 │ │网关 │ │ 市场 │ │ /PPT │ │ Publisher│
│ReAct │ │ 负载 │ │ 安装/ │ │ 生成 │ │ 多平台 │
│Worker│ │ 均衡 │ │ 评分 │ │ │ │ 自动发 │
│ Pool │ │ 故障 │ │ 依赖 │ │ │ │ │
│ x20 │ │ 转移 │ │ 管理 │ │ │ │ │
└──┬───┘ └──┬───┘ └───┬────┘ └───┬────┘ └────┬─────┘
│ │ │ │ │
└────────┴─────────┼───────────┴────────────┘
▼
┌─────────────────┐
│ 存储 + 沙箱 │
│ MySQL + Docker │
│ + QEMU VM │
└─────────────────┘
整个后端是一个 Go 单体服务(没错,单体),原因等下解释。前端是 React + Semi UI,Electron 桌面客户端复用同一份代码。
二、五个最痛的坑
坑 1:LLM 网关到底要不要做?
结论:必须做,而且要早做。
很多人项目里直接 openai.ChatCompletion(...),看起来很省事。但只要你的项目活过 3 个月,一定会遇到:
- 成本飞涨,想切便宜模型(Qwen/DeepSeek)却发现代码改起来一团乱
- API 偶发 5xx,业务直接报错
- 一个客户要走自己的 Azure OpenAI,另一个要用国内代理
- 想加 Token 计费,结果每个调用点都得改
LLM 网关本质上就是一个带路由/负载均衡/重试/计费的反向代理。我的实现核心就 200 行 Go:
type ModelEndpoint struct {
Model string
BaseURL string
APIKey string
Priority int
Weight int
FailCount int
LastFailed time.Time
}
func (r *ModelRouter) SelectEndpoint(model string) (*ModelEndpoint, error) {
endpoints := r.endpoints[model]
if len(endpoints) == 0 {
endpoints = r.endpoints["*"] // 通配
}
// 故障端点冷却 30 秒
var available []*ModelEndpoint
for _, ep := range endpoints {
if ep.FailCount > 0 && time.Since(ep.LastFailed) < 30*time.Second {
continue
}
available = append(available, ep)
}
// 按优先级 + 权重选最佳
sort.Slice(available, func(i, j int) bool {
if available[i].Priority != available[j].Priority {
return available[i].Priority < available[j].Priority
}
return available[i].Weight > available[j].Weight
})
return available[0], nil
}
配上 RoundTripper.RoundTrip 的 3 次重试 + 每分钟健康检查,整个 LLM 网关连同负载均衡 + 故障转移不到 400 行。但它救了我无数次——任何一家 API 抽风都不会让业务挂掉。
坑 2:Agent 引擎不要用 LangChain
我承认,做 PoC 用 LangChain 很爽。但生产环境用 LangChain 是噩梦:
- Python GIL,并发性能差
- 抽象层太厚,出问题难定位
- 依赖一坨,版本冲突频繁
- 流式输出处理混乱
我用 Go 从头写了一个 ReAct 循环 + Worker Pool,核心也就这样:
type Engine struct {
workerPool chan struct{} // 限制并发数
llmClient *llm.Client
tools map[string]Tool
sessions *SessionManager
}
func (e *Engine) ExecuteTask(ctx context.Context, task *Task) error {
e.workerPool <- struct{}{} // 占用一个 worker
defer func() { <-e.workerPool }() // 释放
for step := 0; step < e.maxSteps; step++ {
// 1. 调用 LLM,带工具定义
resp, err := e.llmClient.Chat(ctx, task.Messages, e.toolDefs())
if err != nil {
return err
}
// 2. LLM 决定调哪个工具
if resp.ToolCall == nil {
task.Result = resp.Content
return nil // 完成
}
// 3. 执行工具
tool := e.tools[resp.ToolCall.Name]
result, err := tool.Execute(ctx, resp.ToolCall.Arguments)
if err != nil {
return err
}
// 4. 把工具结果加入对话,继续下一轮
task.Messages = append(task.Messages,
assistantMessage(resp),
toolResultMessage(result),
)
}
return ErrMaxStepsExceeded
}
性能?单机 20 个 worker,稳定跑 10 万 task/天。Python LangChain 同等机器跑 2 万就喘了。
坑 3:文章自动发布不要走"模拟登录 + Cookie"
这是我踩过最深的坑。一开始我用 Playwright + 持久化 Cookie,知乎掘金各写一套登录流程,客户体验灾难:
- 用户得手动 F12 复制 Cookie 粘贴进系统
- Cookie 几天就过期,又要重来
- 知乎更新登录页一次,代码就废一次
正确的姿势是:复用用户已经登录的真实浏览器。
我改用 OpenCLI 的浏览器扩展模式——它通过 CDP 连接到一个本地的真实 Chrome,用户在哪个 Chrome 里登录过,这个浏览器就是哪个,自动化操作那些已登录的标签页。
但这条路也有两个隐藏的大坑:
坑 3.1:Windows 命令行参数会被截断
我把整篇文章的 Markdown 通过命令行参数传给适配器:
exec.Command("opencli", "zhihu", "publish", "--content", longMarkdown)
发布出来发现只有第一段。排查 2 小时才发现:Windows 的 opencli.cmd 是个批处理文件,批处理对 < > & | ^ % 这些字符有特殊解析,任何长 Markdown 一定会被截断。
修复方案:把文章写到临时文件,只传文件路径:
tmpFile, _ := os.CreateTemp("", "publish-*.json")
json.NewEncoder(tmpFile).Encode(map[string]interface{}{
"title": article.Title,
"content": article.Content,
})
exec.Command("opencli", "zhihu", "publish", "--payload", tmpFile.Name())
坑 3.2:Draft.js 不接受原生 dispatchEvent
知乎写作页用的是 Facebook 的 Draft.js 编辑器。我天真地以为 dispatch 一个 ClipboardEvent 就能粘贴 HTML,结果只有第一个 block 进入了编辑器。后面的标题、列表、代码块全部丢失。
原因是 Draft.js 在 React 的合成事件层监听 onPaste,而 dispatchEvent 触发的是原生事件——React 合成事件系统不一定能正确包装。
正确的做法:直接沿 React Fiber 找到 Draft.js Editor 组件实例,调用它内部的 _onPaste:
// 找到 Draft.js Editor 组件实例
let root = editor;
while (root && !root.className.includes('DraftEditor-root')) {
root = root.parentElement;
}
const fiberKey = Object.keys(root).find(k => k.startsWith('__reactFiber'));
let fiber = root[fiberKey];
let inst = null;
while (fiber) {
if (fiber.stateNode && fiber.stateNode._latestEditorState) {
inst = fiber.stateNode;
break;
}
fiber = fiber.return;
}
// 构造一个 Draft.js 期望的 React SyntheticEvent shape
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', plain);
const fakeEvt = {
clipboardData: dt,
nativeEvent: { clipboardData: dt },
preventDefault() { this._dp = true; },
isDefaultPrevented() { return this._dp; },
stopPropagation() {},
persist() {},
type: 'paste',
target: editor,
currentTarget: editor,
_dp: false,
};
inst._onPaste(fakeEvt);
跑通的那一刻 7 个 block 全部进入了编辑器模型层(标题/正文/列表/加粗/代码块),验证一次发布成功。
坑 4:技能(Skill)市场要解耦,不要硬编码
很多人第一次做 Agent,会把工具直接写死在代码里:
if name == "search" { ... }
else if name == "calc" { ... }
else if name == "send_email" { ... }
3 个月后会变成一个 5000 行的 if-else 怪兽。
我的方案是把每个技能做成一个带 manifest 的目录:
data/skills/library/<skill-name>/
├── SKILL.md # YAML frontmatter manifest
├── tools/*.js # Node.js 工具处理器
├── references/ # 文档
└── package.json
SKILL.md 长这样:
---
name: video-generator
version: 1.2.0
description: AI 视频生成,支持文案/TTS/拼接
category: AI
dependencies: ["ffmpeg", "edge-tts"]
tools:
- name: generate_video
description: 根据文案生成视频
handler: tools/generate.js
---
Gateway 启动时扫描所有启用的技能,动态注册成 Agent 工具。新增技能不用改代码,用户在 UI 里点"安装"就能用。
目前我自己写了 20 个内置技能(技术 + 办公),包括视频生成、PPT 生成、知乎掘金发布、PDF 处理、表格处理等等。每个技能都是 100 行级别的 JS,调试起来比传统插件系统快 10 倍。
坑 5:沙箱不要省
如果你打算让 Agent 执行代码、跑 shell、操作文件——一定要上沙箱。
我同时支持两种模式:
- Docker 容器沙箱:轻量,启动快(秒级),适合大部分技能
- QEMU VM 沙箱:重量,完全隔离,适合执行不可信代码
启动配置只是一行:
sandbox:
default_mode: docker # docker | qemu
docker:
image: aaas-sandbox:latest
cpu: 1
memory: 512m
timeout: 30s
上线前我做过一次"红队测试",让一个 Agent 故意跑 rm -rf / 和 fork bomb。结果:主机毫发无伤,沙箱自爆,30 秒后被 reaper 清理。如果不上沙箱,我连晚上都睡不好。
三、几个我特别满意的细节
1. 双模式认证中间件
/v1/chat/completions 同时接受 API Key 和 JWT,用同一个 middleware:
func DualAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
// 先尝试 JWT
if claims, err := parseJWT(token); err == nil {
c.Set("user_id", claims.UserID)
c.Next()
return
}
// 再尝试 API Key
if key, err := lookupAPIKey(token); err == nil {
c.Set("tenant_id", key.TenantID)
c.Next()
return
}
}
c.AbortWithStatus(401)
}
简洁,统一,前端 Web 走 JWT,机器对接走 API Key,后端代码完全不用区分。
2. SSE 多平台并行发布
发布到知乎+掘金不是串行的,而是并发跑两个 goroutine,通过 channel 实时把状态推给前端:
func (e *Engine) Publish(ctx context.Context, req PublishRequest) <-chan Event {
out := make(chan Event, len(req.Platforms)*4)
go func() {
defer close(out)
var wg sync.WaitGroup
for _, platform := range req.Platforms {
wg.Add(1)
go func(p Platform) {
defer wg.Done()
e.publishOne(ctx, p, req.Article, out)
}(platform)
}
wg.Wait()
}()
return out
}
前端通过 SSE 收事件:queued → checking → publishing → success/failed,UI 里两个平台卡片同时变化,体验丝滑。
3. AI 生成文章 → 自动配图 → 一键发布
整套流程是:
- 用户输入主题"Docker 最佳实践"
- 后端调 LLM 生成 title + Markdown content + tags
- 解析关键词,调 Pexels API 检索一张配图
- Markdown 开头插入图片
- 表单自动填好,用户改一改就能发
LLM 提示词的关键是强制结构化输出:
---TITLE---
<标题>
---CONTENT---
<Markdown>
---TAGS---
<tag1>,<tag2>,<tag3>
后端按分隔符切片就行,比让 LLM 输出 JSON 更稳定(LLM 输出 JSON 经常少个引号)。
四、为什么是单体不是微服务?
我知道写到这里一定有人想问:为什么这么多功能不拆微服务?
答:3 万行 Go 单体的运维成本,远小于 5 个微服务的运维成本。
具体来说:
- 一次部署一个二进制,没有版本对齐问题
- 共享 MySQL 连接池,没有跨服务事务
- 共享内存缓存,没有跨服务序列化开销
- 调试时直接断点跟,不用搞分布式追踪
什么时候才该拆?当某个模块的 QPS / 数据库压力 / 团队人数明显独立时再拆。不要预判微服务,90% 的项目根本到不了那个量级。
五、几个数字
跑了一段时间后的真实数据:
| 指标 | 数值 |
|---|---|
| Agent 任务执行 | ~10 万次/天(峰值) |
| LLM 调用 | ~80 万次/天 |
| 平均响应时延 | 1.2s(含 LLM) |
| Worker 并发 | 20 |
| 内存占用 | 180MB |
| 单二进制大小 | 47MB |
部署成本?一台 4 核 8G 的云主机够了。
六、未来要做的
- MCP(Model Context Protocol)支持,接入更多工具生态
- 多智能体协作(像 AutoGen 那种,但走 Go)
- 离线模型支持(Ollama / LM Studio 后端)
- 更多发布平台(CSDN / 微信公众号 / 语雀 / Notion)
写在最后
这篇文章的标题、正文、配图、排版,全部由 AAAS 自己生成,自动发到了你正在看的知乎/掘金。
我没改一个字。
如果你也在做 AI Agent / LLM 应用 / 内容自动化,希望这些踩坑经验能让你少走弯路。
工程最有趣的地方就是:当你把一堆烂事自动化掉之后,你会突然发现自己有时间做更有趣的事了。
点个赞让我知道你看到这里了。有任何问题,评论区见。