검증을 통한 AI 리포트 정확도 향상

Discourse AI는 AI 리포팅 기능을 2년 반 동안 제공해 왔습니다!

이 매우 강력한 기능은 포럼의 방대한 양의 정보를 더 관리하기 쉬운 보고서로 압축할 수 있게 해줍니다. Meta에서 이 기능을 실제로 작동하는 모습을 볼 수 있습니다:

수년 동안 모델들이 더 지능화되었고, 리포팅의 품질도 향상되었습니다. 프런티어 랩이 새로운 모델을 출시할 때마다 보고서 품질이 개선되는 모습을 우리 모두가 지켜봐 왔습니다. 우리 측에서는 아무런 변경 사항이 필요하지 않았습니다.

Discourse 워크플로는 이제 언어 모델을 업그레이드하지 않고도 보고서 품질을 향상시키는 새로운 방법을 제공합니다.

기존의 Discourse AI 리포트 파이프라인은 정적이었습니다:

  1. 컨텍스트 수집
  2. 컨텍스트에 대한 LLM 호출 실행
  3. 게시글 생성

이 단순한 프로세스는 놀라울 정도로 잘 작동하며, 프런티어 모델들은 대량의 데이터를 압축하는 데 탁월합니다.

그러나 가장 지능적인 모델이라 하더라도 때로는 실수를 할 수 있습니다.

워크플로 덕분에 여러 AI 에이전트를 체이닝하여 더 나은 결과를 달성할 수 있습니다.

  • Report Runner 에이전트는 초안 보고서 생성을 담당합니다.
  • Fact checker 에이전트는 에이전틱 루프를 사용하여 초안 보고서를 스캔하고 오류를 수정합니다.

이러한 시스템을 작동시키는 노드들을 자세히 살펴보기

스케줄 노드는 워크플로를 시작하며, 중요한 점은 UTC를 사용한다는 것입니다. 타임존 지원은 향후 추가될 예정입니다.

게시글 목록 노드는 게시글 필터를 선택할 수 있게 해줍니다. 게시글 필터는 토픽 필터와 유사하지만 게시글 수준에서 작동하므로, 하나의 토픽당 여러 결과를 가져올 수 있습니다.

리포팅을 위해 게시글의 원본(raw) 콘텐츠를 포함하는 것은(게시글당 반환되는 정보의 양을 제한하여) 컨텍스트를 관리 가능한 수준으로 유지하는 데 매우 중요합니다.

에이전트가 이해할 수 있도록 보고서를 포맷팅하는 작업은 사용자 정의 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,
    },
  },
];

보고서를 생성할 때 에이전트에 전달되는 데이터의 형태와 사용하는 System Prompt에 대해 완전한 제어권을 가질 수 있다는 점이 주목할 만합니다.

머지 노드는 2개의 보고서를 가져와 하나의 보고서로 병합할 수 있게 해줍니다.

우리 내부의 주간 업데이트는 스태프 업데이트와 내부 커뮤니티의 일반 활동으로 구성됩니다. 이 둘을 하나의 스트림으로 병합함으로써 2개의 서로 다른 소스에서 하나의 일관된 보고서를 생성할 수 있습니다.

마지막으로, 보고서 수정이라는 중대한 작업은 팩트 체커 에이전트가 담당합니다:

에이전틱 루프를 가진 단일 읽기 게시글(read post) 도구가 보고서의 모든 사실을 살펴보고 환각(hallucination)이 없음을 확인하는 주요 작업을 수행할 수 있다는 점이 주목할 만합니다.


이 전략을 통해 우리는 더 적은 비용으로 더 나은 인텔리전스를 얻을 수 있습니다. 우리의 주간 보고서는 이전에 Claude Opus 4.8로 생성되었는데, 이는 DeepSeek v4 Flash보다 더 높은 역량을 갖춘 훌륭한 모델입니다. 그러나 파이프라인과 검증 루프를 적절히 정의함으로써 더 적은 비용으로 더 나은 결과를 얻을 수 있게 되었습니다.

이 튜토리얼이 유용하시길 바랍니다. 궁금한 점이 있으시면 알려주세요.

14개의 좋아요