
2개체의 공진화와 '프롬프트로 보상을 만든다' ― 출제자 × 해답자의 적대적 공진화를 원전으로 읽기【프롬프트로 풀어보는 AI 에이전트 #10】
요약
학습형 AI 에이전트 OSS인 Agent0의 구조를 분석하며, 출제자(Curriculum Agent)와 해답자(Executor Agent)의 적대적 공진화 원리를 다룹니다. 실제 코드를 바탕으로 보상 함수가 프롬프트를 통해 어떻게 생성되고 두 모델이 교대로 훈련되는지 설명합니다.
핵심 포인트
- Agent0는 실행형이 아닌 훈련 프레임워크로서의 골격을 가짐
- 출제자와 해답자는 별도의 환경에서 교대로 훈련되는 공진화 구조
- Qwen3 베이스 모델을 공유하며 프롬프트를 통해 보상을 생성
- 실제 OSS 코드와 디렉토리 구조를 통한 구현 방식 분석
연재 「프롬프트로 풀어보는 AI 에이전트」 제10회 (제8장).
실재하는 3개의 AI 에이전트 OSS를 실제 코드와 실제 프롬프트를 원전에서 인용하며 읽어내어,
최종적으로 「스스로 AI 에이전트를 만들 수 있는」 상태를 목표로 하는 연재입니다.
본 기사는 제2부의 두 번째 —— 학습형 에이전트 Agent0 (aiming-lab/Agent0 @ 30e1882)의 핵심을 다룹니다.
이 장의 전제 (제7장 복습)
전 장 (제7장 「왜 실행이 아니라 진화인가」)에서, Agent0가 **실행 에이전트가 아닌 훈련 프레임워크 (training framework)**라는 점, 그리고 실행형과 학습형은 「같은 for 루프처럼 보이지 않는」 골격을 비교했습니다. 실행형의 reason → act → observe와 나란히 하여, 학습형의 최소 골격은 이러한 흐름이라고 예고했던 것을 기억하시나요.
# 학습형 에이전트 ―― 훈련 시 실행되는 루프 (제7장 예고 의사 코드)
# 등장하는 것은 「두 개의 모델 (의 가중치)」와 「보상 함수 (reward function)」와 「최적화기 (optimizer)」.
# 사용자 입력도 태스크도, 루프 내부에서 생성된다.
...
제7장에서는 (a), (b), (c)의 내용을 추상적인 상태로 두었습니다. 본 장에서는 이 3단계 스텝을 Agent0의 원전 코드까지 내려가 구체화합니다. 중심이 되는 질문은 두 가지입니다.
- 출제자 (Curriculum Agent)와 해답자 (Executor Agent)의 역할 분담은 어디서, 어떻게 구현되어 있는가?
- 「프롬프트로 보상을 만든다」는 것은 구체적으로 무엇을 하고 있는 것인가?
학습 알고리즘 (GRPO / ADPO) 자체의 식이나 그래디언트 (gradient) 구성은 제9장의 영역입니다. 본 장은 (d)의 rl_update(...)에는 발을 들이지 않고, (a), (b), (c) —— 보상이 어떻게 만들어지고, 누가 누구에게 어떤 숫자를 반환하는가까지 읽어냅니다.
역할 분담의 지도 ―― 출제자와 해답자는 별도로 훈련된다
먼저 Agent0의 디렉토리 구조를 보세요. 리포지토리 직하에 Agent0/가 있으며, 그 안에 학습용 2개의 서브트리가 나열되어 있습니다.
Agent0/
├── curriculum_train/ ← 출제자 (Curriculum Agent)의 훈련
│ ├── scripts/curriculum_train.sh
...
—— Agent0/curriculum_train/과 Agent0/executor_train/의 디렉토리 구조 (30e1882)
주목해야 할 점은 훈련이 별도의 디렉토리와 별도의 conda 환경에서 이루어진다는 점입니다. README의 퀵스타트 (Quickstart)는 conda create -n curriculum과 conda create -n executor라는 2개의 환경을 순차적으로 구축하여 별도로 훈련을 돌리는 절차를 보여줍니다 (Agent0/README.md @ 30e1882, L34-48). Agent0의 「공진화 (co-evolution)」는 한쪽의 루프에서 양자를 동시에 최적화하는 것이 아니라, 외부의 이터레이션 (iteration)에서 Curriculum과 Executor를 교대로 훈련하는 형태를 취합니다 (동일 L93-126의 Quickstart는 「출제자 훈련 → 출제자로 문제 생성 → 해답자 훈련」 순서). 본 장에서 읽어내는 것은 이 1 이터레이션 내의 왕복입니다.
양자는 모두 Qwen3의 동일한 베이스 모델 (base model)에서 파생됩니다. README의 Quickstart에서 처음에 주어지는 것은 Qwen3-4B-Base이며, 두 개의 인자 모두 동일한 경로를 전달하고 있습니다.
# Initialize first iteration with base model
bash scripts/curriculum_train.sh Qwen/Qwen3-4B-Base Qwen/Qwen3-4B-Base qwen3_4b_curriculum_v1
—— Agent0/README.md @ 30e1882, L93-94
첫 번째 인자가 executor_agent_path, 두 번째 인자가 curriculum_agent_path (후술할 curriculum_train.sh
L5-6). 초기화 단계에서는 동일한 가중치를 두 개 복사하여 시작하며, 훈련을 거치면서 점차 서로 다른 능력으로 분화되어 갑니다. 출제자와 해답자는 처음에는 쌍둥이였으나, 경쟁 속에서 서로 다른 인격체가 됩니다. 이것이 '공생적 경쟁 (symbiotic competition, README L9)'의 구조적 기반입니다.
출제자 (Curriculum Agent) 해부
출제자의 프롬프트 ―― 출력 계약이 보상의 입구
출제자의 핵심은 하나의 시스템 프롬프트 (system prompt)입니다. Agent0의 훈련 데이터셋을 구축하는 클래스 (RLHFDataset) 내에서, format_prompt 인수의 문자열에 questioner_format이 포함되어 있는 경우의 메시지 구성은 다음과 같습니다.
if "questioner_format" in self.format_prompt:
# print('detected questioner_format')
return [
...
―― Agent0/curriculum_train/verl/utils/dataset.py @ 30e1882, L167-194
다음 세 가지 요소로 분해할 수 있습니다.
- 역할의 선언:
You are an expert competition-math problem setter.(당신은 경시 수학 문제 출제 전문가입니다.) - 난이도 설계 지시:
Aim for a difficulty such that fewer than 30 % of advanced high-school students could solve it.(상급 고등학생의 30% 미만만이 풀 수 있는 난이도를 목표로 하십시오.) /Avoid re-using textbook clichés or famous contest problems.(교과서의 클리셰나 유명한 경시 문제를 재사용하는 것을 피하십시오.) - 출력 계약 (output contract):
<question>...</question>+\boxed{final_answer}이 두 블록 외에는 아무것도 출력하지 마십시오 (Do NOT output anything else).
제1부의 실행형 에이전트 (executor agent)를 통독해 온 독자라면, 표면적으로는 이것이 제2장 「인격·행동 규범의 시스템 프롬프트」와 유사해 보일 것입니다. 역할을 부여하고, 행동 지시를 내리며, 응답 계약을 규정하는 ―― 구조는 동형(isomorphic)입니다.
하지만, 이 <question> 태그와 \boxed{}가 후속 단계에서 어떻게 사용되는가가 실행형 시스템 프롬프트와는 결정적으로 다릅니다. 실행형에서 이들에 대응하는 것은 「tool_name JSON」과 같이 루프의 디스패치 (dispatch)에 사용하는 응답 계약이었습니다 (제3장). Agent0에서는, 이 출력 계약이 보상 함수 (reward function)의 정규 표현식과 직접 맞물립니다. 나중에 확인할 curriculum_reward.py의 re.findall(r"<question>(.*?)</question>", ...)와 extract_boxed_content(...)가 바로 그것입니다.
페르소나로 다양성을 부스트하는 파생형
동일한 dataset.py에는 또 다른 출제자 프롬프트가 있습니다. questioner_format_with_persona를 지정하면, 시스템 프롬프트의 맨 앞에 '당신은 누구인가'를 페르소나 풀 (persona pool)에서 하나 뽑아 삽입합니다.
if "questioner_format_with_persona" in self.format_prompt:
print("load personas")
return [
...
―― Agent0/curriculum_train/verl/utils/dataset.py @ 30e1882, L139-149 (이후 생략된 부분은 앞서 언급한 questioner_format과 동일)
self.personas는 HuggingFace의 PersonaHub에서 math 스플릿을 로드한 집합입니다 (동일 L129-133). "당신은 대학원에서 대수 기하학을 연구하고 있습니다", "당신은 수학 올림피아드 금메달리스트입니다"와 같은 구체적인 인물상을 매 롤아웃 (rollout)마다 무작위로 삽입하여, 출제 스타일 자체에 다양성을 부여하는 메커니즘입니다. 이는 나중에 살펴볼 '다양성 페널티 (diversity penalty)'와 쌍을 이루고 있습니다. 즉, 출제자가 비슷한 문제만 양산하면 손해를 보게 설계되어 있으며, 그렇기에 입력 단계에서 다양성의 씨앗을 뿌리는 것입니다.
동일한 문구는 훈련 후 데이터를 생성하는 측의 스크립트에서도 나타납니다. Agent0/curriculum_train/question_generate/question_generate.py
@ 30e1882
, L58-83의 `chat = [{
def format_reward(predict: str) -> float:
pattern = re.compile(r"<think>.</think>.\boxed{.}.", re.DOTALL)
format_match = re.fullmatch(pattern, predict)
...
capped_calls = min(tool_call_count, cap)
return capped_calls * weight
def compute_score(predicts: List[str], ground_truths: List[str], format_weight: float = 0.1, file_path: str = "") -> List[Dict[str, float]]:
results = []
with open('test.json','w') as f:
json.dump(predicts,f,indent=4)
for i in tqdm(range(len(predicts)), desc=" - Parsing predictions"):
questions = re.findall(r"<question>(.*?)</question>", predicts[i], re.DOTALL)
answers = extract_boxed_content(predicts[i])
if questions and answers:
try:
question = questions[-1].strip()
answer = answers[-1].strip()
results.append({"question": question, "answer": answer})
except:
results.append({"question": "", "answer": ""})
else:
results.append({"question": "", "answer": ""})
final_results = generate_results(results)
penalty = cluster_share_per_problem([result['question'] for result in final_results], distance_threshold=0.5)
assert len(penalty) == len(final_results)
scores = []
for i in tqdm(range(len(final_results)), desc=" - Calculating final scores"):
final_score = (min(final_results[i]["score"],1-final_results[i]["score"]) if final_results[i]['question'] else -1)-penalty[i]+calculate_tool_reward(predicts[i])
scores.append({"overall": final_score,"format": 1 if final_results[i]['question'] else 0,"accuracy": penalty[i],"tool_reward": calculate_tool_reward(predicts[i])})
return scores
―― Agent0/curriculum_train/examples/reward_function/curriculum_reward.py
―― Agent0/curriculum_train/examples/reward_function/curriculum_reward.py
def _bleu_distance_matrix(sentences):
n = len(sentences)
dist = np.zeros((n, n))
smoother = SmoothingFunction().method1
for i in tqdm(range(n), desc=" - Calculating BLEU distance matrix", leave=False):
for j in range(i, n):
if i == j:
score = 1.0
else:
ref = [sentences[j].split()]
hyp = sentences[i].split()
score = sentence_bleu(ref, hyp, smoothing_function=smoother)
dist[i, j] = dist[j, i] = 1 - score
return dist
def cluster_share_per_problem(
problems,
distance_threshold: float = 0.5,
linkage: str = "average"):
if not problems:
return []
print('start clustering')
start_time = time.time()
dist_mat = _bleu_distance_matrix(problems)
clustering = AgglomerativeClustering(
n_clusters=None,
distance_threshold=distance_threshold,
metric="precomputed",
linkage=linkage
)
labels = clustering.fit_predict(dist_mat)
print(f'end clustering, time: {time.time() - start_time}')
total = len(problems)
cluster_size = Counter(labels)
cluster_ratio = {lab: sz / total for lab, sz in cluster_size.items()}
proportions = [cluster_ratio[lab] for lab in labels]
return proportions
―― Agent0/curriculum_train/examples/reward_function/curriculum_reward.py
...
def calculate_tool_reward(predict: str, weight: float = 0.05, cap: int = 4) -> float:
if not predict:
return 0.0
tool_call_count = len(re.findall(r"```output", predict))
capped_calls = min(tool_call_count, cap)
return capped_calls * weight
―― Agent0/curriculum_train/examples/reward_function/curriculum_reward.py
@ 30e1882
, L117-125
주목해야 할 점은 이 함수의 인자(argument)가 출제 주체 자체의 롤아웃 출력(predict)이라는 것입니다. compute_score 본문(L150)에서는 calculate_tool_reward(predicts[i]) 형태로 호출하고 있으며, 여기서 predicts[i]는 출제 주체가 한 번의 롤아웃을 통해 생성한 텍스트(<question>...</question>와 oxed{} 쌍)입니다. 즉, 출제 주체의 출력 안에 ```output 이라는 문자열 패턴이 몇 번 나타났는지를 세고 있다는 의미로 해석할 수 있습니다. 이는 답변 주체가 실제로 도구를 호출한 흔적이 아니라,
출제 주체 자신의 생성 텍스트에 포함된 도구 실행의 흔적을 최대 4회까지 카운트하여...
`pressure the curriculum agent to generate complex, tool-aware tasks`
(`출제역에게 도구 의식이 높은 문제를 생성하도록 압력을 가한다`)를, 출제역의 롤아웃 (rollout)에 대한 자기 채점으로 구현하고 있는 모습입니다.
#### 보상의 종합 ―― 프롬프트와 보상의 맞물림
제1~3항을 합친 최종 스코어의 구조를 한 줄로 요약하면 다음과 같습니다.
overall = min(p, 1-p) − cluster_share + tool_reward if <question>\boxed{} 있음
= −1 if 출력 계약 위반
각 항목이 프롬프트의 어느 부분에 대응하는지 나열해 보겠습니다.
| 보상 항목 | 대응하는 출제역 프롬프트 |
|---|---|
| `min(p, 1-p)`의 볼록 함수 (convex function) | `Aim for a difficulty such that fewer than 30 % of advanced high-school students could solve it.` (난이도 가이드) |
| `<question>` 추출 실패로 인한 `-1` | `output **exactly** the following two blocks: <question>...</question> \boxed{final_answer}` / `Do NOT output anything else` (출력 계약) |
| 다양성 페널티 | `Avoid re-using textbook clichés or famous contest problems.` (클리셰 금지) + 페르소나 주입 |
| 도구 보너스 (tool bonus) | (프롬프트에 명시되지 않음. 해답역 측 프롬프트의 ````output` 계약에 의존) |
**프롬프트에 자연어로 작성한 "이런 문제를 만들어라"가 후속 단계의 수치 보상과 1대1로 맞물려 있다**는 점이 핵심입니다. 이것이 "프롬프트로 보상을 만든다"의 구체적인 모습입니다. 보상 모델 (Reward Model, 인간의 선호도를 학습한 별도의 네트워크)도, 인간의 라벨링도 원리적으로 불필요합니다. 필요한 것은 오직 "`<question>` ... ````output`을 세는 정규 표현식"뿐입니다.
이는 실행형 "응답 계약" (agent-zero의 `tool_name` JSON 등)과는 역할이 다릅니다. 실행형에서의 응답 계약은 **루프의 디스패치 (dispatch)에 사용**됩니다 (해당 JSON을 읽어 도구를 호출함). 반면 Agent0의 응답 계약은 **보상의 자동 채점에 사용**됩니다. 같은 "계약"이라는 용어가 추론 시의 디스패치와 훈련 시의 자동 채점이라는 서로 다른 상황에서 작동하고 있다는 뜻입니다.
## 해답역 (Executor Agent) 해부
### 해답역의 프롬프트 (도구 미사용 버전)
앞 절의 `min(p, 1-p)`를 계산하려면, 출제역이 만든 문제를 **실제로 누군가에게 풀게 하여 p를 산출**해야 합니다. 이를 수행하는 것이 배후에서 동작하는 해답역의 vLLM 서버입니다.
해답역은 문제를 읽고 답안을 작성합니다. 그 역할로 롤아웃을 생성할 때 사용하는 시스템 프롬프트는 `dataset.py`의 다른 분기에서 찾을 수 있습니다.
```python
if "solver_format" in self.format_prompt:
return [
{
...
―― Agent0/curriculum_train/verl/utils/dataset.py @ 30e1882, L195-205
단 한 줄입니다. Please reason step by step, and put your final answer within \boxed{}. (단계별로 추론하고, 최종 답변은 \boxed{} 안에 넣어라).
동일한 한 줄은 question_evaluate/evaluate.py @ 30e1882, L102에서도 chats = [[{"role": "system", "content": "Please reason step by step, and put your final answer within \\boxed{}."}, ...] for q in questions]라는 형태로 재사용되며, 생성된 문제를 해답역에게 풀게 하여 정답률을 측정하는 장면에서 나타납니다.
최소한의 응답 계약 (\boxed{}로 최종 답변을 감싸는 것)뿐입니다. 해답역의 시스템 프롬프트는 출제역보다 훨씬 얇다는 점이 관찰 포인트입니다. 이유는 단순합니다. 해답역은 "\boxed{} 안의 내용이 정답과 일치하는가"만이 보상으로 물어지기 때문에, 응답 계약의 제약은 거기에 국한되기 때문입니다 (앞서 언급한 accuracy_reward, L113-115).
해답자 역할의 프롬프트 (도구 통합 버전)
해답자 역할에게 코드 실행을 허용하는 설정이라면, 시스템 프롬프트 (System Prompt)는 조금 더 북적거리게 됩니다. vllm_service_init/start_vllm_server_tool.py의 서두에 정의된 상수가 이것입니다.
SYSTEM_PROMPT = (
"다음 문제를 단계별로 해결하세요. 당신은 이제 추론 과정을 강화하기 위해 실행 가능한 Python 코드를 선택적으로 작성할 수 있는 능력을 갖추었습니다.\n"
"먼저, 당신의 추론을 제공하고 정답 계산을 돕기 위해 ```python ... ```로 감싸진 독립적인 Python 코드 블록을 작성하세요. 결과를 출력하기 위해서는 반드시 `print()` 함수를 사용해야 합니다.\n"
"코드 블록을 작성한 후에는 멈추세요 (STOP). 제가 대신 실행해 드리겠습니다.\n"
"그 후 저는 'Code execution result:' 아래에 출력 결과를 제공할 것입니다. 당신은 이 결과(오류인 경우라도)를 사용하여 추론을 계속하고 최종 정답을 제공해야 합니다.\n"
"최종 정답은 \boxed{...} 안에 작성되어야 합니다.\n"
...\n"중간 결과를 출력하기 위해 print()를 사용해야 합니다.\n"
"답변 형식:\n"
"당신 응답의 마지막 부분은 다음과 같아야 합니다: \boxed{...}"
)
―― Agent0/curriculum_train/vllm_service_init/start_vllm_server_tool.py
...
python ...
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기