最近在搭一个 Agent 社区 / 机器人协作社区。核心想法不是再做一个聊天群,而是有一个更像“议事厅”的地方:人类可以发布任务、讨论设计、沉淀决策;机器人可以被 @、被分配任务、补充资料、执行工具,并把结果写回同一个讨论串。

这次先落地了第一步:在服务器上部署一套 Discourse + Discourse AI

先澄清一下:这里部署的是 Discourse,一个可以自托管的论坛/社区系统;不是 Discord。Discord 是外部 SaaS 聊天平台,后续可以作为实时入口接进来,但不是本文的部署主体。

最终入口:

ChatArch Agent Community 首页

目标

这次部署的目标是:

  1. 使用官方推荐的 discourse_docker 部署 Discourse。
  2. 不让 Discourse 容器直接占用公网 80/443,而是绑定本机端口,再由 Nginx 反代。
  3. 接入现有的 local/public 双域名入口。
  4. 启用 Discourse AI,并接入一个 OpenAI-compatible LLM。
  5. 初始化 Agent 社区需要的分类和标签。
  6. 先进入可验证的 staging 状态,SMTP 后续再补。

完成后的状态:

项目 结果
Discourse 已部署
运行方式 官方 discourse_docker
容器名 app
端口绑定 127.0.0.1:3088 -> container:80
反向代理 Nginx local vhost
Public 入口 由已有 public-entry 自动转回 local
Discourse AI 已启用
AI Bot 已启用
LLM ark-code-latest
SMTP 尚未配置,当前为 staging/bootstrap 状态

目录规划

长期服务不要放在一次性 project 目录里。这里把 Discourse 放到 ChatArch 的长期服务目录:

1
/home/zhihong/.chatarch/discourse/

目录结构大致是:

1
2
3
4
5
6
7
/home/zhihong/.chatarch/discourse/
docker/ # discourse_docker checkout
docker/containers/app.yml
shared/standalone/ # Discourse 持久数据
secrets/ # 服务器本地 secret,只本机可读
backups/
logs/

部署过程、报告、脚本和验证日志则放在项目目录里:

1
2
3
4
5
6
7
/home/zhihong/Playground/projects/agent-community/07-27-discourse-ai-service/
PRD.md
progress.md
links.md
reports/
playground/
scripts/

这样区分之后,长期服务和一次性任务记录不会混在一起。

前置条件

这台服务器已有:

  • Ubuntu 22.04 系列环境。
  • Docker 27.x。
  • Docker Compose v2。
  • Nginx。
  • 已有 wildcard 证书。
  • 已有 local/public 双域名入口机制。

Discourse 官方生产部署推荐走 Docker,也就是 discourse_docker。源码裸部署理论上可以,但 Rails、PostgreSQL、Redis、Sidekiq、Nginx、assets、升级和迁移都要自己维护,不适合作为第一版长期服务。

第一步:准备服务目录

1
2
3
mkdir -p /home/zhihong/.chatarch/discourse/{secrets,logs,backups}
chmod 700 /home/zhihong/.chatarch/discourse/secrets
cd /home/zhihong/.chatarch/discourse

然后 clone 官方 Docker 部署仓库:

1
2
git clone https://github.com/discourse/discourse_docker.git docker
cd /home/zhihong/.chatarch/discourse/docker

如果已经 clone 过,就只需要更新或检查当前状态。

第二步:生成 app.yml

Discourse 的 Docker 部署核心配置在:

1
/home/zhihong/.chatarch/discourse/docker/containers/app.yml

这份配置基于官方 samples/standalone.yml 修改。关键点是:

  1. 只把容器端口绑定到本机。
  2. 使用 existing Nginx 做 HTTPS 终止和反代。
  3. 开启 bundled Discourse AI plugin。
  4. 暂时跳过 SMTP bootstrap。
  5. 后续把 canonical hostname 设置为 public 域名。

示意配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
templates:
- templates/postgres.template.yml
- templates/redis.template.yml
- templates/web.template.yml
- templates/web.ratelimited.template.yml

expose:
- "127.0.0.1:3088:80"

params:
db_default_text_search_config: "pg_catalog.english"

env:
LANG: en_US.UTF-8
DISCOURSE_DEFAULT_LOCALE: zh_CN
DISCOURSE_HOSTNAME: "discourse.public.wzhecnu.cn"
DISCOURSE_FORCE_HTTPS: true

# 当前是 staging/bootstrap 状态,生产化前需要配置 SMTP
DISCOURSE_SKIP_EMAIL_SETUP: 1
DISCOURSE_SMTP_ADDRESS: smtp.example.invalid

当前 Discourse AI 已经在 Discourse 主仓库的 plugins/discourse-ai 中维护;如果你的版本没有 bundled plugin,需要按官方当前文档添加插件,不要照抄旧的 standalone discourse-ai 仓库。关键原则是不把 API key、SMTP 密码、admin password 写进普通项目文档。

第三步:bootstrap 并启动容器

执行官方 launcher:

1
2
3
cd /home/zhihong/.chatarch/discourse/docker
./launcher bootstrap app
./launcher start app

启动后验证容器:

1
docker ps --filter name=app --format 'table {{.Names}}\t{{.Ports}}\t{{.Status}}'

这次结果是:

1
2
3
container: app
binding: 127.0.0.1:3088->80/tcp
restart policy: always

本机探测:

1
curl --noproxy '*' -I http://127.0.0.1:3088/

应返回 HTTP/1.1 200 OK 或在 Rails 启动初期短暂返回 503,稍等后恢复。

第四步:配置 Nginx local vhost

我们的 WZHECNU 域名入口有一套约定:

1
2
<service>.local.wzhecnu.cn  -> 本机 local nginx vhost
<service>.public.wzhecnu.cn -> public-entry 自动转回 local

也就是说,普通服务接入时,只应该在本机 Nginx 添加 local vhost,不需要为每个服务改 DNS、改 FRP、改 public tunnel,也不应该在本机 Nginx 里额外写 public host。

Discourse 的 Nginx 配置路径:

1
2
/etc/nginx/sites-available/discourse-local.conf
/etc/nginx/sites-enabled/discourse-local.conf

核心配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
server {
listen 80;
server_name discourse.local.wzhecnu.cn;

client_max_body_size 100m;

location / {
proxy_pass http://127.0.0.1:3088;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Ssl on;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300;
proxy_send_timeout 300;
}
}

server {
listen 443 ssl;
server_name discourse.local.wzhecnu.cn;

ssl_certificate /etc/nginx/cert/wzhecnu-wildcard/fullchain.pem;
ssl_certificate_key <wildcard-cert-privkey>;

client_max_body_size 100m;

location / {
proxy_pass http://127.0.0.1:3088;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Ssl on;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 300;
proxy_send_timeout 300;
}
}

然后测试并 reload:

1
2
sudo nginx -t
sudo systemctl reload nginx

本机 local 验证:

1
2
3
curl --noproxy '*' -k -I \
--resolve discourse.local.wzhecnu.cn:443:127.0.0.1 \
https://discourse.local.wzhecnu.cn/

Public 验证:

1
curl --noproxy '*' -k -I https://discourse.public.wzhecnu.cn/

第五步:处理 local/public 和 canonical hostname

这里踩了一个坑。

一开始只配置了 discourse.local.wzhecnu.cn,但本机 HTTP vhost 写成了:

1
return 301 https://$host$request_uri;

public 请求进入 public-entry 后,已经正确回到了本机 local vhost。但这个 local vhost 又把浏览器重定向到:

1
https://discourse.local.wzhecnu.cn/

如果访问者不在 local/overlay 网络里,就访问不了这个 local 域名。修复方法不是把 public host 写进本机 Nginx,而是让 local HTTP vhost 直接 proxy 到 Discourse。

第二个坑是 Discourse 的 canonical URL。

Discourse 不是只用相对链接的简单应用,它会根据 DISCOURSE_HOSTNAME 生成 RSS、OpenSearch、图片、topic、用户页等绝对链接。如果这个值是:

1
DISCOURSE_HOSTNAME=discourse.local.wzhecnu.cn

那么 public 页面里也会出现一堆 local 绝对链接。因此最终把它改成:

1
DISCOURSE_HOSTNAME=discourse.public.wzhecnu.cn

然后重新创建容器,让环境变量生效:

1
2
3
cd /home/zhihong/.chatarch/discourse/docker
./launcher destroy app
./launcher start app

数据放在 /shared 持久卷里,重新创建容器不会删除站点数据。

验证:

1
2
3
4
5
docker exec -u discourse -i app bash -lc '
cd /var/www/discourse && \
RAILS_ENV=production bundle exec rails runner \
"puts({hostname: Discourse.current_hostname, base_url: Discourse.base_url}.to_json)"
'

结果应类似:

1
{"hostname":"discourse.public.wzhecnu.cn","base_url":"https://discourse.public.wzhecnu.cn"}

第六步:创建管理员账号

因为 SMTP 还没配置,不能依赖邮件完成管理员注册。可以用 Rails runner 创建/激活管理员账号。

管理员密码只存放在服务器本地 secret 文件里:

1
/home/zhihong/.chatarch/discourse/secrets/admin.env

注意:不要把密码、API key、SMTP password 写进博客、项目文档或聊天记录。

管理员最终状态需要满足:

1
2
3
active: true
admin: true
moderator: true

第七步:启用 Discourse AI

Discourse AI 是 Discourse 官方生态里的 AI 插件能力集合。它适合做:

  • AI Bot / Persona
  • 写作辅助
  • Topic 总结
  • 审核/治理辅助
  • 后续接 embedding 后的语义搜索

它不是完整的多 Agent 调度器,不能替代 Agent Router。

这次配置结果:

1
2
3
4
5
6
7
8
{
"ai_enabled": true,
"ai_helper_enabled": true,
"ai_bot_enabled": true,
"default_llm": "1",
"llm_count": 1,
"bot_user": "ark-code-latest1"
}

LLM 使用 ChatArch 的 OpenAI-compatible profile,API key 存在 Discourse 的 AiSecret,不写进普通文件。容器内验证 LLM 调用返回:

1
{"status": 200, "ok": true, "has_choices": true}

也就是说,Discourse 容器内部能访问模型 endpoint。

第八步:初始化 Agent 社区分类和标签

初始分类:

1
2
3
4
5
6
7
Tasks
Research
Design
Decisions
Agent Runs
Agents
Meta

初始标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
task
research
design
decision
agent-run
status-open
status-running
status-waiting-human
status-accepted
route-skill
route-infra
route-archive
agent-curator
agent-critic
agent-scribe
agent-router
approval-required

Discourse 分类页

这些分类和标签的目标是让 Discourse 不只是“聊天论坛”,而是逐步成为一个面向任务和产物的 Agent 社区:

1
2
3
4
Discourse Topic/Post/Tag/Mention
-> Agent Router
-> Hermes / Codex / Claude Code / other runtimes
-> Discourse reply / ChatBoard / Git / Docs

第九步:系统调优

Bootstrap 时 Redis 提示 vm.overcommit_memory 未开启。按 Discourse/Redis 长期运行建议,持久化:

1
2
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-chatarch-discourse.conf
sudo sysctl -p /etc/sysctl.d/99-chatarch-discourse.conf

验证:

1
sysctl -n vm.overcommit_memory

应返回:

1
1

第十步:验证页面

最终验证:

1
curl --noproxy '*' -k -I https://discourse.public.wzhecnu.cn/

页面标题:

1
ChatArch Agent Community

公开页面:

Discourse 标签页

当前还没完成的部分

这套部署现在是可用的 staging / internal 状态,但还不是完整生产状态。

最重要的缺口是 SMTP。

当前使用了:

1
DISCOURSE_SKIP_EMAIL_SETUP=1

这意味着:

  • 页面能访问。
  • 管理员能登录。
  • AI 能力能用。
  • Topic / 分类 / 标签能用。

但这些还不能算生产完成:

  • 邀请邮件
  • 通知邮件
  • 找回密码
  • 邮件确认

后续 SMTP 可以有几种路线:

  1. 使用现有可靠外部 SMTP。
  2. 使用企业/个人邮箱的 SMTP bootstrap。
  3. 另行部署 DMS/docker-mailserver 作为长期邮件服务。

如果走 DMS,还需要 MX、A、PTR/rDNS、SPF、DKIM、DMARC、TLS 证书和端口策略,这应该作为另一个独立任务做。

总结

这次部署的关键点不是“把 Discourse 跑起来”这么简单,而是把它放进 ChatArch 的长期服务和 Agent 社区架构里:

1
2
3
4
5
Discourse = 结构化讨论社区和 canonical topic space
Discourse AI = 社区内 AI 辅助体验
Agent Router = 后续连接 mention/tag/category/webhook 到外部 Agent runtime
Hermes / Codex / CC Connect = Agent runtime / gateway / tool execution
ChatBoard / Git / Blog = 长期产物沉淀

这一步完成后,我们已经有了一个可以真实发帖、讨论、沉淀和测试 AI 能力的社区入口。下一步可以开始做最小 Agent Router:监听 Discourse webhook,识别 @researcher@scribe,调用 Agent Runtime,再把结果写回原 topic。