산업 전반에서 다양한 에이전트에 걸쳐 매우 일반적으로 사용되는 패턴 중 하나가 **에이전트 스킬(Agent Skills)**입니다.
스킬은 에이전트의 컨텍스트 문제를 해결합니다.
모든 지침과 아이디어를 거대한 단일 시스템 프롬프트에 쑤어 넣는 대신, 에이전트 스킬은 점진적 공개(progressive disclosure)를 가능하게 합니다.
에이전트는 자신이 할 수 있는 것을 알고, 이를 수행하는 방법을 찾기 위해 매우 구체적인 도구를 호출합니다.
이것은 Discourse에서 우리가 스킬 카탈로그를 토픽으로 유지하면서 편집기, 히스토리, 권한 관리 기능을 활용할 수 있기 때문에 상당한 이점을 제공합니다.
Discourse AI에서 커스텀 도구를 사용하면 두 가지 매우 강력한 기능을 제공하므로 이 시스템을 근사적으로 구현할 수 있습니다.
- 커스텀 도구는 시스템 프롬프트 수정을 허용합니다.
- 커스텀 도구는 특정 카테고리의 토픽과 게시물을 읽을 수 있습니다.
우리의 목표
Discourse에서 스킬을 근사적으로 구현하기 위해 우리는 다음을 원합니다.
- 스킬당 하나의 토픽
- 시스템 프롬프트 내 자동 생성되는 사용 가능한 스킬 디렉터리
예를 들어, 다음과 같은 형태(원본)의 토픽을 원합니다:
---
name: doc-coauthoring
description: Guide users through a structured workflow for co-authoring documentation. Use when the user wants to write a proposal, technical specification, decision record, or similar document.
---
# Document co-authoring workflow
1. Gather context from the user.
2. Agree on an outline.
3. Draft and refine each section.
4. Test the document from a new reader's perspective.
```\n
이것은 시스템 프롬프트에서 다음과 같이 변환되어야 합니다:
```xml
<available_skills>
<skill>
<name>doc-coauthoring</name>
<description>Guide users through a structured workflow for co-authoring documentation...</description>
<location>https://example.com/t/skill-doc-coauthoring/123</location>
</skill>
... more skills here ...
</available_skills>
이러한 특정 형식을 선택한 이유는 많은 언어 모델이 이미 이 특정 형태를 찾는 데 파인 튜닝되어 있어 리콜(recall) 성능을 향상시킬 수 있기 때문입니다. 실제 스킬의 본문이 빠져 있음을 주목하십시오.
그런 다음, 모델이 사용자가 문서 공동 작성 워크플로를 시도하고 있음을 감지하면 실제 지침을 가져오기 위해 커스텀 도구 load_skill을 호출합니다.
이것이 설정되면, Discourse에서 에이전트가 사용하도록 원하는 스킬을 포함하는 전용 카테고리를 정의하고 커뮤니티 멤버들이 이를 반복적으로 개선할 수 있도록 허용할 수 있습니다.
작동 방식
load_skill의 전체 로직은 커스텀 도구를 사용하여 JavaScript로 작성됩니다.
discourse.filterTopics를 호출하여 특정 카테고리의 토픽을 검색할 수 있습니다.
discourse.getPost를 호출하여 게시물 본문을 가져올 수 있습니다.
customSystemMessage는 커스텀 시스템 프롬프트를 주입하는 데 사용됩니다.
이러한 빌딩 블록을 사용하여
먼저 새로운 커스텀 도구를 정의합니다:
- 이름: load_skill
- 매개변수:
- “name”: “available_skills의 스킬 이름”
다음으로 스킬 파싱을 학습시킵니다:
var SKILLS_CATEGORY_SLUG = "agent-skills";
var SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function skillTopics() {
var result = discourse.filterTopics({
q: "category:" + SKILLS_CATEGORY_SLUG + " order:created",
limit: 200,
});
return result && Array.isArray(result.topics) ? result.topics : [];
}
function topicOp(topic) {
if (!topic || !topic.first_post_id) {
return null;
}
return discourse.getPost(topic.first_post_id);
}
function frontmatter(raw) {
if (typeof raw !== "string") {
return null;
}
var match = raw.match(
/^---[ \t]*\n([\s\S]*?)\n---(?:[ \t]*\n|$)/
);
return match ? match[1] : null;
}
function scalarField(yaml, field) {
var match = yaml.match(
new RegExp("^" + field + ":[ \\t]*(.+)[ \\t]*$", "m")
);
return match ? match[1].trim() : null;
}
function xmlEscape(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/\"/g, """)
.replace(/'/g, "'");
}
function skillRecord(topic) {
var post = topicOp(topic);
var yaml = post && frontmatter(post.raw);
if (!yaml) {
return null;
}
var name = scalarField(yaml, "name");
var description = scalarField(yaml, "description");
if (
!name ||
!description ||
!SKILL_NAME_PATTERN.test(name) ||
name.length > 64 ||
description.length > 1024
) {
return null;
}
return {
name: name,
description: description,
location: discourse.baseUrl + topic.url,
content: post.raw,
};
}
function availableSkills() {
var records = [];
var topics = skillTopics();
for (var i = 0; i < topics.length; i++) {
var record = skillRecord(topics[i]);
if (record) {
records.push(record);
}
}
records.sort(function (a, b) {
return a.name.localeCompare(b.name);
});
return records;
}
SKILLS_CATEGORY_SLUG을 스킬이 포함된 카테고리의 실제 슬러그로 설정하는 것을 잊지 마십시오.
다음으로 customSystemMessage를 사용하여 시스템 프롬프트를 주입합니다.
function customSystemMessage() {
var skills = availableSkills();
if (skills.length === 0) {
return null;
}
var lines = [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the load_skill tool to load a skill when a task matches its description.",
"<available_skills>",
];
skills.forEach(function (skill) {
lines.push(" <skill>");
lines.push(" <name>" + xmlEscape(skill.name) + "</name>");
lines.push(
" <description>" +
xmlEscape(skill.description) +
"</description>"
);
lines.push(
" <location>" + xmlEscape(skill.location) + "</location>"
);
lines.push(" </skill>");
});
lines.push("</available_skills>");
return lines.join("\n");
}
마지막으로, 슬러그에서 스킬 내용으로 이어지는 스킬 로더를 구성합니다.
function invoke(parameters) {
var requestedName = parameters && parameters.name;
if (typeof requestedName !== "string") {
return "No skill name was provided.";
}
requestedName = requestedName.trim();
if (!SKILL_NAME_PATTERN.test(requestedName)) {
return "Skill names must use lowercase letters, numbers, and hyphens.";
}
var matches = availableSkills().filter(function (skill) {
return skill.name === requestedName;
});
if (matches.length === 0) {
return "No available skill has the name " + requestedName + ".";
}
if (matches.length > 1) {
return "More than one skill has the name " + requestedName + ".";
}
var skill = matches[0];
return [
'<skill_content name="' + xmlEscape(skill.name) + '">',
"# Skill: " + skill.name,
"",
skill.content.trim(),
"",
"Source topic for this skill: " + skill.location,
"</skill_content>",
].join("\n");
}
완벽하게 작동하는 에이전트 + 커스텀 도구 JSON은 여기에서 사용할 수 있으며, 인스턴스에 가져와서 필요에 맞게 수정할 수 있습니다:
category-skills-agent.json (5.9 KB)
실제 작동 모습
한계
현재 데모는 여전히 모든 사용자 턴마다 스킬이 포함된 모든 게시물을 로드하는 N+1 호출을 수행합니다. 대규모 환경에서는 이것이 권장되지 않을 수 있습니다.
워크플로를 사용하여 스킬 카탈로그 게시물을 자동으로 생성하도록 설계를 수정하거나, filterTopics를 확장하여 접두사(prefix)를 요청하는 호출을 허용할 수 있습니다.
또한 에이전트 스킬은 리소스와 스크립트를 지원하며, 리소스는 “read” 도구를 허용하고 스킬에서 특정 토픽으로 링크를 걸어서 근사적으로 구현할 수 있습니다. 셸 실행은 명확하게 Discourse의 범위 밖입니다.
이 글이 유용하길 바랍니다


