Cafe24 LLM Router 모델 목록에 Solar Pro 4가 추가됐습니다.
이 글에서는 Solar Pro 4가 어떤 강점을 가진 모델인지, 그리고 어떤 업무에 붙이면 되는지를 다룹니다. 예시는 쇼핑몰 운영에서 자주 나오는 세 가지 업무입니다. 후기 답글 초안 쓰기, 후기에서 상세페이지에 빠진 내용 찾기, 아침 매출 브리핑 — 셋 다 기존 환경에서 그대로 구현하는 코드를 함께 실었습니다.
Solar Pro 4는 어떤 모델인가요
| 항목 | 값 |
|---|---|
| 모델 ID | solar-pro4 |
| 제공사 | Upstage |
| 컨텍스트 | 512K 토큰 |
| 최대 출력 | 128K 토큰 |
| 언어 | 한국어, 영어, 일본어 (입출력 모두) |
| 추론 조절 | reasoning effort — 높이면 심층 분석, 낮추면 실시간 응답 |
| 호출 방식 | OpenAI 호환 /chat/completions, 도구 호출 지원 |
512K 컨텍스트에 최대 128K 출력입니다. 계약서 여러 건과 리포트, 데이터 파일을 한 세션에서 대조하는 작업이 청크 분할 없이 동작합니다.
운영 업무는 일반 AI 채팅과 요구가 다릅니다
쇼핑몰이든 사내 시스템이든, 운영 자동화에서 모델에게 맡기는 일은 단순한 대화가 아닙니다.
하나, 입력이 깁니다. 후기 3,000건, CS 로그, 현재 상세페이지를 한 번에 검토해야 하는 업무가 있습니다. 잘라서 넣으면 결과가 달라지는 종류의 업무입니다.
둘, 지어내면 안 됩니다. 여기서 나온 문장은 고객에게 그대로 나가거나 상세페이지에 올라갑니다. 상세페이지에 올라간 문장은 광고 문구가 됩니다. 그럴듯한 창작은 오답보다 위험합니다.
셋, 매일 반복됩니다. 이게 운영 업무의 제일 중요한 부분입니다. 후기는 매일 수십 건씩 들어오고, 아침 브리핑은 1년에 365번 돌고, 상세페이지 점검은 매주 상품 수만큼 돕니다. 한 번 잘 되는 것과 1년 내내 잘 되는 것은 다른 문제입니다.
반복은 두 가지를 동시에 요구합니다. 매일 돌려도 감당되는 비용, 그리고 매번 믿을 수 있는 결과입니다. 둘 중 하나라도 빠지면 오래가지 못합니다. 최상위 프리미엄 모델은 성능은 충분하지만 매일 수백 건씩 돌리기엔 부담이 크고, 가장 저렴한 모델은 틀린 결과를 사람이 다시 확인해야 해서 결국 아낀 만큼을 인건비로 돌려줍니다.
여기에 하나 더 있습니다. 입력도 출력도 전부 한국어입니다. 후기는 "핏이 좀 애매스", "생각보다 쩜 두꺼움" 같은 고객의 문장으로 쓰이고 읽힙니다.
반복해도 흔들리지 않는 성능이 기준이 됩니다
반복 업무에서 실제로 지불하는 건 1M 토큰당 단가가 아니라, 완료된 작업 1건당 비용에 반복 횟수를 곱한 값입니다.
도구를 네 단계 호출하는 브리핑 업무를 생각해 보겠습니다. 3단계에서 흐트러지는 모델은 재시도가 붙고, 재시도가 붙으면 토큰을 두 배 세 배로 씁니다. 그러고도 사람이 손으로 확인해야 합니다. 하루 한 번이면 넘어갈 수 있지만 365번 반복되면 이야기가 달라집니다. 한 번에 끝내는 모델이 반복 업무에서는 결국 저렴합니다.
| 이런 업무에 | 왜 |
|---|---|
| 매일·매주 반복되는 업무 | 한 번에 끝내면 재시도 비용이 붙지 않습니다 |
| 긴 문서를 통째로 읽는 업무 | 512K 컨텍스트가 결과 자체를 바꿉니다. 잘라서 넣으면 다른 답이 나옵니다 |
| 출력이 고객이나 상세페이지로 나가는 업무 | 근거 기반 응답이 후처리 필터로 그대로 쓰입니다 |
| 한국어 구어를 읽고 쓰는 업무 | 후기도 답글도 전부 한국어입니다 |
Solar Pro 4의 강점은 최고 성능이 아니라 반복해도 흔들리지 않는 성능입니다. 매일 수백 번 도는 한국어 실무를 맡길 모델을 찾는다면 기준은 두 개로 좁혀집니다. 매번 같은 품질의 답변을 내는지, 그리고 AI 사용 비용을 감당할 만한지입니다. Solar Pro 4는 그 두 가지를 함께 만족시킬 수 있는 모델입니다.
업무마다 필요한 모델이 다르면 라우터에서 프리셋으로 나눠 저장해 두시면 됩니다. 나중에 바꾸는 것도 콘솔에서 끝납니다.
아래 세 워크플로우가 각각 위 특성에 대응합니다. 셋 다 한 번 만들면 계속 반복되는 업무입니다.
| 워크플로우 | 반복 주기 | 필요한 특성 |
|---|---|---|
| ① 후기 답글 초안 | 후기 등록마다 (하루 수십 건) | 한국어 + 믿을 수 있는 출력 |
| ② 후기에서 상세페이지에 빠진 내용 찾기 | 주 1회 × 상품 수 | 512K 컨텍스트 + 근거 기반 |
| ③ 아침 매출 브리핑 | 매일 1회 (연 365회) | 여러 단계를 흐트러뜨리지 않고 완주 |
모델 값만 바꾸면 테스트가 시작됩니다
이미 라우터를 사용 중이시라면 바꿀 게 많지 않습니다. model에 solar-pro4를 넣으시면 됩니다.
res = client.chat.completions.create(
model="solar-pro4", # ← 이 줄만 바꾸면 된다
messages=[...],
)
도구 호출도 OpenAI 호환 스펙 그대로 동작하고, 아래에서 쓰는 라우터 옵션은 전부 extra_body로 들어갑니다.
먼저 응답만 확인해 보시려면 이렇게 호출합니다.
curl https://llm-router.cafe24.com/api/v1/chat/completions \
-H "Authorization: Bearer $ROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"solar-pro4","messages":[{"role":"user","content":"연결 확인"}]}'
키는 업무별로 나눠두시면 편합니다
아래 세 워크플로우를 다 만드실 거라면 키를 나누는 편이 낫습니다. 사용량이 섞이면 어느 업무가 크레딧을 소모하는지 알 수 없고, 배치 잡이 루프에 빠졌을 때 차단 범위도 넓어집니다.
ROUTER_KEY_REVIEW 후기 답글 초안 크레딧 한도 낮게
ROUTER_KEY_REVIEW_MINING 후기에서 상세페이지 보완점 찾기 크레딧 한도 중간
ROUTER_KEY_BRIEF 아침 브리핑 크레딧 한도 낮게
워크플로우 ① — 후기 답글 초안
이런 흐름으로 동작합니다
후기 등록 웹훅 핸들러에서 시작합니다. 이미 후기 등록 시 알림을 보내거나 통계를 갱신하는 훅이 있다면 거기에 한 단계를 추가하시면 됩니다.
POST /webhooks/review-created
→ 후기 + 주문 맥락 조회 (내부 DB / Open API)
→ 큐에 적재 (동기 처리 금지)
↓ 워커
→ solar-pro4 호출
→ drafts 테이블에 저장
→ 어드민 검수 큐에 노출
→ [사람] 확인 후 게시
웹훅 핸들러 안에서 모델을 직접 호출하지 않는 게 좋습니다. 응답이 수 초 걸리고, 후기가 몰리는 시간대에 훅 타임아웃이 발생합니다. 큐에 넣고 워커에서 처리합니다.
주문 맥락을 함께 전달합니다
SYSTEM = """당신은 온라인 쇼핑몰 운영자를 대신해 후기 답글 초안을 쓴다.
규칙:
1. 후기에 실제로 적힌 표현을 최소 1개 그대로 인용한다.
2. 후기에 없는 사실을 덧붙이지 않는다.
3. 사과·보상·환불·재발송을 약속하지 않는다. 판단은 운영자가 한다.
4. 3문장 이내, 존댓말.
5. 후기 내용을 인용할 수 없으면 답글 대신 "인용 불가"라고만 출력한다."""
def draft_reply(review, order):
tone = (
"불편을 인지했다는 사실을 먼저 적되, 원인을 단정하지 않는다."
if review.rating <= 2
else "고마움을 짧게 적고, 후기에서 언급된 부분을 한 번 되짚는다."
)
res = client.chat.completions.create(
model="solar-pro4",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"""[후기]
별점: {review.rating}
본문: {review.body}
[주문 맥락]
상품: {order.product_name}
옵션: {order.option}
배송 소요: {order.delivery_days}일
재구매 여부: {'예' if order.is_repeat else '아니오'}
[이번 건 톤]
{tone}"""},
],
temperature=0.7,
extra_body={
"cache": {"semantic": False},
"metadata": {"task": "review-reply", "env": ENV},
},
)
return res.choices[0].message.content
cache 옵션의 semantic을 False로 두는 게 이 업무의 핵심입니다. 시맨틱 캐시는 의미가 비슷한 요청에 저장된 응답을 돌려줘 비용을 아끼는 기능인데, 답글 업무에서 켜두면 비슷한 후기에 똑같은 답글이 붙습니다. 그래서 이 업무에서만 요청 단위로 꺼둡니다.
temperature 0.7도 같은 이유입니다. 낮추면 문장이 일정해지는데, 답글이 너무 일정해지면 템플릿과 구분되지 않습니다.
템플릿으로는 왜 안 되나요
"배송은 빨랐는데 마감이 아쉽네요"라는 후기에 "빠른 배송에 만족해 주셔서 감사합니다"가 달리면, 그 답글은 달지 않은 것보다 나쁩니다. 읽지 않았다는 증거가 공개적으로 남기 때문입니다.
프롬프트 1번 규칙(원문 표현 인용)이 그걸 막는 장치이고, 5번 규칙("인용 불가")이 실패를 명시적으로 드러내는 장치입니다. 인용할 근거를 찾지 못했을 때 그럴듯한 문장을 만들어내는 대신 찾지 못했다고 말하기 때문에, 워커에서 그 신호를 받아 검수 큐에 플래그를 달고 사람이 직접 작성하도록 넘길 수 있습니다.
워크플로우 ② — 후기에서 상세페이지에 빠진 내용 찾기
고객이 상품에 대해 궁금해하는 것과, 상세페이지에 실제로 적혀 있는 것 사이에는 늘 틈이 있습니다. 그 틈은 이미 후기와 CS 문의에 남아 있습니다. 같은 질문이 반복해서 올라오는데 상세페이지에는 그 답이 없는 항목을 찾아, 상세페이지 FAQ로 만드는 작업입니다.
이런 흐름으로 동작합니다
주 1회 배치 잡입니다. cron이든 스케줄러든 이미 사용하시는 것을 그대로 쓰시면 됩니다.
0 4 * * 1 → 상품별 루프
후기 전량 + CS 로그 + 현재 상세페이지 조회
→ solar-pro4 호출 (JSON 강제)
→ 빈도 10회 이상 + 미언급 항목만 필터
→ suggestions 테이블 적재
→ [사람] 확인 후 상세페이지 반영
현재 상세페이지 본문을 함께 전달하는 게 중요합니다. 없으면 모델이 이미 적혀 있는 내용까지 발견 항목으로 출력합니다.
잘라서 넣으면 안 됩니다
후기 3,000건에 "사이즈가 좀 작다"가 예순 번 나온다고 가정해 보겠습니다. 100건씩 잘라 요약하면 각 조각에서는 "1~2건 언급"으로 보이고 요약 단계에서 버려집니다. 조각을 다시 합쳐도 이미 사라진 뒤입니다. 반복 신호는 전체를 한 번에 읽을 때만 보입니다.
후기 3,000건이면 수십만 자 규모인데, 512K 컨텍스트라면 분할 없이 한 세션에 들어갑니다. 이 업무에서 컨텍스트 길이는 편의 기능이 아니라 결과를 바꾸는 조건입니다.
SYSTEM = """당신은 쇼핑몰 후기와 CS 문의를 읽고 상세페이지 개선점을 찾는다.
반드시 지킬 것:
- 입력에 실제로 등장한 표현만 사용한다. 추론하거나 일반화하지 않는다.
- 각 항목의 근거로 원문을 그대로 인용한다. 요약하지 않는다.
- 현재 상세페이지 본문에 이미 언급된 항목은 covered=true로 표시한다.
- 인용을 3개 못 채우는 항목은 결과에서 제외한다.
아래 스키마의 JSON 객체로만 출력한다.
출력 형식:
{"findings":[{"issue":"한 줄 요약","category":"사이즈|소재|배송|색상|사용법|기타",
"frequency":정수,"quotes":["원문1","원문2","원문3"],"covered":true|false,
"suggested_faq":{"q":"질문","a":"후기 근거에 기반한 답변"}}]}"""
def mine_reviews(product_id: str) -> list[dict]:
reviews = fetch_reviews(product_id) # 내부 DB 또는 Open API
tickets = fetch_cs_tickets(product_id)
page = fetch_product_description(product_id)
corpus = "\n".join(f"[후기{r.id}] 별점 {r.rating} / {r.body}" for r in reviews)
ticket_text = "\n".join(f"[문의{t.id}] {t.body}" for t in tickets)
res = client.chat.completions.create(
model="solar-pro4",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"""[현재 상세페이지 본문]
{page}
[후기 {len(reviews)}건]
{corpus}
[CS 문의 {len(tickets)}건]
{ticket_text}"""},
],
temperature=0.2,
response_format={"type": "json_object"},
extra_body={"metadata": {"task": "page-mining", "product": product_id}},
)
findings = json.loads(res.choices[0].message.content)["findings"]
return [f for f in findings if f["frequency"] >= 10 and not f["covered"]]
여기서 한 번 걸렸던 지점이 있습니다. response_format을 json_object로 지정하면서 프롬프트 어디에도 "JSON"이라는 단어가 없으면, 요청 자체가 400으로 거절됩니다.
400 provider_error: To use 'response_format' of type 'json_object',
the 'messages' must include the word 'json' in some form.
위 프롬프트의 "아래 스키마의 JSON 객체로만 출력한다" 한 줄이 그 조건을 만족시킵니다. 이 줄을 넣고 나서야 정상 응답이 돌아왔습니다.
프롬프트에서 가장 중요한 줄은 "입력에 실제로 등장한 표현만 사용한다"와 "인용을 3개 못 채우는 항목은 제외한다"입니다. 여기서 추출된 문장은 상세페이지로 올라가고, 상세페이지에 올라간 문장은 광고 문구가 됩니다.
인용 3개 규칙이 자동 필터로 동작합니다. 실제로 반복된 항목은 인용 세 개를 찾기 어렵지 않고, 모델이 만들어낸 항목은 여기서 걸립니다. covered 플래그도 마찬가지로 후처리에서 그대로 활용할 수 있는 신호입니다.
워크플로우 ③ — 아침 매출 브리핑
이런 흐름으로 동작합니다
매일 오전 8시에 도는 크론 잡입니다. 앞의 둘과 성격이 다르게 새 글을 쓰는 게 아니라 여러 API를 순서대로 조회하는 업무입니다.
0 8 * * * → 어제 브리핑을 briefings 테이블에서 로드
→ 도구 호출 루프
get_sales(어제) / get_sales(그저께)
get_inventory(임계 이하)
get_reviews(어제, 별점 2 이하)
→ 형식에 맞춰 조립
→ 슬랙 발송 + briefings 테이블에 저장
"3일 연속 하락" 같은 문장은 어제 결론이 남아 있어야 나옵니다. 여기서 필요한 건 별도의 에이전트 런타임이 아니라 테이블 하나입니다. 어제 브리핑 텍스트를 그대로 프롬프트에 전달하시면 됩니다. 30일치를 매번 다시 전달하는 것보다 토큰이 훨씬 적게 듭니다.
도구 호출은 직접 디스패치합니다
라우터는 도구를 실행해 주지 않습니다. 스펙을 모델에 전달하고, 모델이 호출을 요청하면 자사 코드가 실행해서 결과를 되돌려주는 루프를 직접 구현합니다. 개별 호출 하나가 정확한지가 아니라, 매출 조회 → 재고 조회 → 비교 → 조립 네 단계를 중간에 흐트러뜨리지 않고 끝까지 수행하는지가 관건입니다.
TOOLS = [
{"type": "function", "function": {
"name": "get_sales",
"description": "지정한 날짜의 매출 합계와 상품별 매출을 반환한다.",
"parameters": {"type": "object",
"properties": {"date": {"type": "string", "description": "YYYY-MM-DD"}},
"required": ["date"]}}},
{"type": "function", "function": {
"name": "get_inventory",
"description": "잔여 재고가 임계치 이하인 상품 목록을 반환한다.",
"parameters": {"type": "object",
"properties": {"threshold_days": {"type": "integer"}},
"required": ["threshold_days"]}}},
{"type": "function", "function": {
"name": "get_reviews",
"description": "지정 날짜의 후기를 별점 필터와 함께 반환한다.",
"parameters": {"type": "object",
"properties": {"date": {"type": "string"}, "max_rating": {"type": "integer"}},
"required": ["date"]}}},
]
BRIEF_FORMAT = """다음 형식으로만 출력한다. 데이터가 없는 항목은 줄째로 생략한다.
📊 {날짜} 아침 브리핑
- 어제 매출: {금액} ({전일 대비 %}, {연속 추세})
- 하락/상승 기여 1위: {상품명} ({변동 %}, {가능한 원인})
- 재고 경고: {상품명} 잔여 {수량}개 ({소진 예상일})
- 후기: 어제 {건수}건 중 별점 2 이하 {건수}건
숫자는 도구가 반환한 값만 쓴다. 추정하지 않는다."""
DISPATCH = {
"get_sales": get_sales,
"get_inventory": get_inventory,
"get_reviews": get_reviews,
}
def morning_brief(today: str, yesterday_brief: str) -> str:
messages = [
{"role": "system", "content": BRIEF_FORMAT},
{"role": "user", "content": f"""오늘은 {today}입니다.
필요한 데이터를 도구로 조회해 브리핑을 작성하세요.
추세 판단을 위해 어제와 그저께 매출을 모두 조회하세요.
[어제 브리핑]
{yesterday_brief}"""},
]
for _ in range(MAX_TURNS): # 무한루프 방지
res = client.chat.completions.create(
model="solar-pro4",
messages=messages,
tools=TOOLS,
temperature=0.1,
extra_body={"metadata": {"task": "daily-brief"}},
)
msg = res.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for call in msg.tool_calls:
fn = DISPATCH[call.function.name]
result = fn(**json.loads(call.function.arguments))
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
raise RuntimeError("도구 호출 턴 한도 초과")
MAX_TURNS로 턴을 제한해야 합니다. 크론 잡이 도구 호출 루프에 갇히면 크레딧이 밤새 소진됩니다. 키별 크레딧 한도가 2차 방어선이지만, 1차는 코드에 두는 편이 맞습니다.
"숫자는 도구가 반환한 값만 쓴다. 추정하지 않는다" 한 줄이 이 업무의 안전장치입니다. 모델이 매출 흐름을 그럴듯하게 보간하면 그 숫자로 발주가 나갑니다. temperature 0.1도 같은 이유입니다. 여기서는 창의성이 전부 손해입니다.
세 업무 설정은 프리셋으로 관리합니다
세 업무의 설정이 서로 다르다는 게 계속 나왔습니다. 이걸 코드에 흩어두면 나중에 수정하기 어렵습니다.
| 업무 | 모델 | 캐시 | temperature | 실행 |
|---|---|---|---|---|
| 후기 답글 | solar-pro4 | OFF | 0.7 | 웹훅 → 큐 → 워커 |
| 상세페이지 보완점 찾기 | solar-pro4 | ON | 0.2 | 주 1회 배치 |
| 아침 브리핑 | solar-pro4 | ON | 0.1 | 매일 08:00 크론 |
라우터의 프리셋은 모델과 폴백 모델, 프로바이더 라우팅, 샘플링 파라미터(temperature·top_p·max_tokens 등), 시스템 프롬프트를 하나의 설정으로 저장하는 기능입니다. 콘솔에서 만들고 코드에서는 이름으로만 호출합니다.
client.chat.completions.create(model="@preset/review-reply", messages=[...])
이렇게 해두시면 temperature 같은 샘플링 값이나 모델 선택을 배포 없이 콘솔에서 조정할 수 있습니다. 프리셋을 수정할 때마다 버전 스냅샷이 저장되어 수정 이력이 버전으로 남습니다.
캐시는 프리셋에 담기는 항목이 아닙니다. 시맨틱 캐시는 API 키 단위 설정(콘솔)이나 요청의 cache 옵션으로 조절합니다. 워크플로우 ①에서 쓴 extra_body의 cache 설정이 요청 단위로 끄는 방법입니다.
설정은 Account → Preset → Request 순으로 병합되고, ignore는 합집합, only는 교집합입니다. 프로바이더가 데이터를 학습에 쓰지 못하게 하려면 요청 또는 프리셋의 프로바이더 설정에 data_collection을 넣습니다. 최상위 필드가 아니라 provider 하위 필드입니다. 최상위로 보내면 400이 떨어집니다.
extra_body={"provider": {"data_collection": "deny"}}
사용량은 요청에 실어 보낸 metadata 기준으로 나눠서 확인합니다.
curl "https://llm-router.cafe24.com/api/v1/activity?from=2026-08-01&to=2026-08-31" \
-H "Authorization: Bearer $ROUTER_KEY"
일자 × 모델 × 프로바이더로 집계되고, 청구 기준은 usage_krw_micro 필드입니다. usage는 OpenRouter 호환을 위한 USD 값으로, 환율을 환산한 게 아니라 USD 단가로 직접 산출합니다. USD 단가가 등록되지 않은 모델이 섞이면 그 값이 null이 되고 응답의 usd_complete가 false로 표시되니, 정산은 usage_krw_micro를 기준으로 하시면 됩니다.
인프라부터 준비해야 한다면
여기까지는 서버, DB, 스케줄러가 이미 있다는 전제였습니다. 그게 없는 경우 — 쇼핑몰은 운영 중인데 상시 워커를 실행할 곳이 없거나, 팀이 작아서 인프라 관리에 시간을 쓰기 어려운 경우 — 라면 카페24 Hermes Agent VPS가 그 역할을 대신합니다.
기억, 스킬 저장, 스케줄러를 제공하고, 쇼핑몰 스킬 팩을 업로드하면 상품·주문·후기 조회를 인증부터 직접 구현하지 않으셔도 됩니다. 라우터 연결은 프로바이더 목록에서 Custom endpoint를 선택하고 Base URL과 키를 입력하면 호환 모드를 자동 인식합니다.
반대로, 위 세 워크플로우를 이미 있는 환경에서 구현할 수 있다면 굳이 새 VPS를 준비하실 이유는 없습니다. 라우터는 어느 쪽에서든 동일하게 동작합니다.
알아두면 좋은 점
턴과 예산 한도는 코드와 콘솔 양쪽에 설정합니다. 루프는 MAX_TURNS로, 총액은 키별 크레딧 한도로 잡습니다. 한쪽만 있으면 반드시 새는 날이 옵니다.
업무별 설정은 프리셋으로 나눠둡니다. 세 업무의 모델과 temperature가 이미 다릅니다. 프리셋으로 분리해 두시면 조정이 배포 없이 콘솔에서 끝납니다. 캐시는 프리셋이 아니라 API 키 단위 설정이나 요청의 cache 옵션으로 관리한다는 점만 기억하시면 됩니다.