Discourse AI 已提供 AI 报告 功能两年半了!
这个极其强大的功能允许你将论坛上的大量信息压缩成更易管理的报告。你可以在 Meta 上看到它的实际效果:
多年来,模型变得越来越智能,报告的质量也随之提高。我们都目睹了每次前沿实验室发布新模型时报告质量的提升。我们无需进行任何更改。
Discourse 工作流 现在提供了一种在不升级语言模型的情况下提高报告质量的新方法。
最初的 Discourse AI 报告管道是静态的:
- 收集上下文
- 在上下文中运行 LLM 调用
- 创建帖子
这个简单的过程效果出奇地好,前沿模型在压缩大量数据方面表现出色。
然而,即使是最聪明的模型有时也会出错。
工作流允许我们将多个 AI 代理链接起来以获得更好的结果。
- 我们的报告运行器代理负责创建报告草稿。
- 我们的事实核查员扫描报告草稿,并使用代理循环纠正错误
深入查看使此类系统正常工作的节点
计划节点启动工作流,需要注意的是它使用的是 UTC 时间。时区支持将在未来添加。
列表帖子节点允许我们选择帖子过滤器,帖子过滤器类似于 主题过滤器,只不过它们在帖子级别运行,因此你可以为每个主题获取多个结果。
包含帖子的原始内容对于我们的报告(以及限制每个帖子返回的信息量)至关重要,以保持上下文可控
使用自定义 JavaScript 块格式化报告,以便代理能够理解
const items = $input.all();
function asArray(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function formatDate(value) {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toISOString().slice(0, 16).replace("T", " ");
}
function truncate(text, maxLength) {
if (!text) {
return "";
}
const value = String(text);
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength).trimEnd()}...`;
}
function postFromItem(item) {
const json = item.json || {};
return json.post || json;
}
const posts = items
.map((item) => postFromItem(item))
.filter((post) => post && post.id && post.topic_id);
const summary = {
returned_posts: posts.length,
returned_topics: new Set(posts.map((post) => post.topic_id)).size,
total_likes: posts.reduce((sum, post) => sum + Number(post.like_count || 0), 0),
top_users: [],
};
const users = new Map();
for (const post of posts) {
const username = post.username || "unknown";
const existing = users.get(username) || {
username,
posts: 0,
likes: 0,
};
existing.posts += 1;
existing.likes += Number(post.like_count || 0);
users.set(username, existing);
}
summary.top_users = Array.from(users.values())
.sort((left, right) => {
if (right.likes !== left.likes) {
return right.likes - left.likes;
}
if (right.posts !== left.posts) {
return right.posts - left.posts;
}
return left.username.localeCompare(right.username);
})
.slice(0, 10);
const topicMap = new Map();
for (const post of posts) {
const topicId = post.topic_id;
if (!topicMap.has(topicId)) {
topicMap.set(topicId, {
id: topicId,
title: post.topic_title || `Topic ${topicId}`,
slug: post.topic_slug,
category_id: post.category_id,
category_name: post.category_name,
tags: asArray(post.tags),
post_likes: 0,
posts: [],
});
}
const topic = topicMap.get(topicId);
topic.post_likes += Number(post.like_count || 0);
const tags = asArray(post.tags);
for (const tag of tags) {
if (!topic.tags.includes(tag)) {
topic.tags.push(tag);
}
}
if ((post.raw || '') !== '') {
topic.posts.push({
id: post.id,
topic_id: post.topic_id,
post_number: post.post_number,
reply_to_post_number: post.reply_to_post_number,
post_url: post.post_url,
username: post.username,
user_id: post.user_id,
created_at: post.created_at,
created_at_formatted: formatDate(post.created_at),
updated_at: post.updated_at,
excerpt: post.excerpt,
raw: post.raw,
cooked: post.cooked,
like_count: Number(post.like_count || 0),
reply_count: Number(post.reply_count || 0),
score: Number(post.score || 0),
});
}
}
const topics = Array.from(topicMap.values())
.map((topic) => {
topic.posts.sort((left, right) => {
return Number(left.post_number || 0) - Number(right.post_number || 0);
});
return topic;
})
.sort((left, right) => {
if (right.post_likes !== left.post_likes) {
return right.post_likes - left.post_likes;
}
return String(left.title).localeCompare(String(right.title));
});
let context = "";
context += "## Summary\n\n";
context += `Returned posts: ${summary.returned_posts}\n`;
context += `Returned topics: ${summary.returned_topics}\n`;
context += `Total likes in returned posts: ${summary.total_likes}\n\n`;
context += "Top users in returned posts:\n";
for (const user of summary.top_users) {
context += `@${user.username} (${user.likes} likes, ${user.posts} posts)\n`;
}
context += "\n## Topics\n";
for (const topic of topics) {
context += `\n### ${topic.title}\n\n`;
context += `topic_id: ${topic.id}\n`;
if (topic.category_name) {
context += `category: ${topic.category_name}\n`;
}
if (topic.tags.length > 0) {
context += `tags: ${topic.tags.map((tag) => `#${tag}`).join(", ")}\n`;
}
context += `sample_likes: ${topic.post_likes}\n`;
let lastPostNumber = 0;
for (const post of topic.posts) {
if (post.post_number && lastPostNumber && post.post_number > lastPostNumber + 1) {
context += "\n...\n";
}
context += "\n";
context += `post_id: ${post.id}\n`;
context += `post_number: ${post.post_number}\n`;
if (post.post_url) {
context += `url: ${post.post_url}\n`;
}
if (post.created_at_formatted) {
context += `${post.created_at_formatted}\n`;
}
if (post.username) {
context += `user: @${post.username}\n`;
}
context += `likes: ${post.like_count}\n`;
const body = post.raw || post.excerpt || "";
context += `${truncate(body, 4000)}\n`;
lastPostNumber = Number(post.post_number || lastPostNumber);
}
}
return [
{
json: {
summary,
topics,
context,
},
},
];
值得注意的是,在创建报告时,你可以完全控制传递给代理的数据形状及其使用的系统提示。
合并节点允许我们将两份报告合并为一份报告。
我们内部的每周更新由员工更新和我们内部社区的一般活动组成。通过将两者合并到一个流中,我们可以从两个不同的来源创建一份连贯的报告。
最后,纠正报告的重任留给了事实核查员代理:
值得注意的是,单个带有代理循环的读取帖子工具能够完成主要工作,即遍历报告中的每个事实并确保其没有幻觉。
这种策略使我们能够以更少的成本获得更智能的结果。我们的每周报告以前是使用 Claude Opus 4.8 生成的,这是一个比 DeepSeek v4 Flash 更强大的优秀模型。然而,通过正确定义管道和验证循环,我们能够以更少的成本获得更好的结果。
希望你觉得本教程有帮助,如果你有任何问题,请告诉我。













