Files

15 KiB
Raw Permalink Blame History

AGENTS.md · n8n 사용량·비용 관리 작업 규칙 (75일차 실습 폴더)

이 파일은 CLI 코딩 에이전트(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 74일차 규칙과 같고, 3절이 오늘 만드는 비용이 보이는 헬프데스크 · 비용 보고서의 계약, 5절에 토큰이 보이는 LLM 호출(HTTP) · 단가 · 사용량 기록 · 예산 · 한도 · 질문 길이 상한 노드가 더해졌습니다.

1. 작업 순서 (항상 이 순서)

  1. 요청을 읽고, 만들 워크플로우의 입력 · 노드 순서 · 결과 모양을 3~5줄로 먼저 정리해 보여 준다. 비용 요청이면 LLM 호출이 몇 번, 어느 모델로, 무엇을 보내는지도 함께 보여 준다.
  2. 워크플로우 JSON을 my-work/<이름>.json에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
  3. uv run tools/flow.py check my-work/<이름>.json → 오류(✘)가 없을 때까지 고친다.
  4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
    uv run tools/flow.py push my-work/<이름>.json
    uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json -v
    
  5. push와 test는 사람이 실행해 결과를 확인한다. 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨). 테스트는 실제 OpenAI 호출이라 돈이 든다. 같은 테스트를 여러 번 반복하지 않는다.
  6. 사람이 실패 보고서(runs/*.json)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. 같은 곳을 3번 고쳐도 실패하면 멈추고 다른 방법을 제안한다.
  7. 문서(my-work/*.md)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.

2. 환경 (바꾸지 말 것)

항목 값
n8n 2.42.6, 65일차 공식 설치 ~/day65-n8n (Docker 프로젝트 day65-n8n, 컨테이너 보통 day65-n8n-n8n-1), 브라우저 http://localhost:5678
n8n 켜고 끄기 ~/aio-01d4-stu/Last3Mon/65/n8n에서 docker compose start/stop (사람이 함, Ollama 컨테이너는 없음)
게시된 웹훅 주소 밖에서 http://localhost:5678/webhook/<path>
OpenAI Credential id openAiCourse0001, name OpenAI (.env). 모델 이름: gpt-4.1-mini, gpt-5.4-nano (같은 Credential). gpt-4.1-nano는 OpenAI가 더 이상 받지 않음
다른 Credential 쓰지 않음 (Ollama 등 없음)
데이터 API n8n 안에서 http://data-api:8800, 터미널에서 http://localhost:8800 (75일차 판 Last3Mon/75/data-api: 74일차 + 단가 · 사용량 기록 · 예산 · 한도)
비교 기준 reference/baseline-cost.json (웹훅 base-cost), reference/lab02-example3-nano-answer.json (웹훅 nano-answer). 고치지 않는다

데이터 API (75일차 판)

요청 응답
GET /prices {"currency": "USD", "unit": "per 1M tokens", "models": {"gpt-4.1-mini": {"input": 0.4, "output": 1.6}, "gpt-5.4-nano": {"input": 0.2, "output": 1.25}}}
POST /usage 본문 {"items": [{"request_id", "user", "step", "model", "prompt_tokens", "completion_tokens", "cost_usd", "route"}]} (LLM 호출 하나에 항목 하나) → {"stored": <항목 수>, "total"} · 고장 스위치 usage가 error면 500, slow면 8초 뒤 503(저장하지 않음)
GET /usage/summary {"requests"(request_id 수), "calls", "prompt_tokens", "completion_tokens", "cost_usd", "by_model", "by_user", "by_route"} (by_*마다 calls · prompt_tokens · completion_tokens · cost_usd)
GET /usage/user/<이름>?window=60 {"user", "window", "count"} — 그 사람의 최근 N초 요청 수 (이동 창)
GET /budget {"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"} (used_usd는 기록된 cost_usd의 합)
POST /budget · /budget/reset · /usage-clear 예산 · 한도 바꾸기 · 처음으로(data/budget.json: $1.0 · 20번/분 · 500자) · 기록 비우기 (사람이 flow.py budget · usage --clear로, 테스트가 before · after로)
POST /admin/fault {"usage": "ok" | "slow" | "error"} (faq · tickets · prompts도) · flow.py fault usage error
GET /faq?q= · GET /tickets/<id>?requester= 72~74일차와 같음 (접수 건에는 assignee · contact도 있음)
/prompts · /releases · /gates · /audit · /cache 72~74일차와 같음 (오늘 쓰지 않음)

3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)

무엇 파일 · 웹훅 입력 (본문) 결과
헬프데스크 my-work/cost.json · POST /webhook/my-cost (처음은 reference/baseline-cost.json을 복사) question(필수), user {"route", "answer", "sources", "llm_calls", "usage": {"prompt_tokens", "completion_tokens", "by_step": {"<단계>": {"model", "prompt_tokens", "completion_tokens", "cost_usd"}}}, "cost_usd", "recorded", "trimmed", "question_chars"}
비용 보고서 my-work/cost-report.json · POST /webhook/my-cost-report requests_per_day(없으면 지금 요청 수), workdays(기본 22), what_if_model(있으면) {"requests", "calls", "cost_usd", "avg_usd", "by_model": {"<모델>": cost}, "top_user", "projected_month_usd", "daily_budget_usd", "remaining_usd", "over_budget", "what_if": {"model", "cost_usd", "saving_usd", "projected_month_usd"}}
  • cost_usd = Σ(입력 토큰 × input 단가 + 출력 토큰 × output 단가) / 1,000,000, 단계마다 그 단계 모델의 단가로. 단가는 /prices에서 읽는다 (워크플로우에 숫자를 쓰지 않는다). 소수 8자리에서 반올림.
  • route: 자동 답변 · 한도 초과(하루 예산을 넘음 — LLM 없이 FAQ · 접수 원문, llm_calls 0 · cost_usd 0) · 잠시 후(사용자별 1분 한도를 넘음 — HTTP 429, retry_after: 60).
  • recorded(Lab 01 과제 2): 기록 API가 stored ≥ 1을 돌려주면 true. 기록이 실패 · 3초 넘게 걸려도 답은 그대로 보낸다.
  • trimmed · question_chars(Lab 02 과제 2): 질문이 /budget의 max_question_chars보다 길면 앞 floor(상한 × 0.4)자 + ' … ' + 뒤 나머지 글자만 LLM · FAQ 검색에 쓴다. question_chars는 원래 글자 수.
  • 순서: 사용자별 한도(가장 앞) → 단가 · 질문 줄이기 → FAQ · 접수 조회 → 하루 예산 → 규칙 분류 → (모를 때만) LLM 분류 → 답 LLM → 기록.
  • 비용 보고서의 금액은 소수 6자리에서 반올림 (반올림은 마지막에). projected_month_usd = (cost_usd ÷ requests) × requests_per_day × workdays. top_user는 비용이 가장 큰 사람. 보고서는 LLM을 쓰지 않는다.
  • what-if: 기록된 모든 호출의 입력 · 출력 토큰 합을 what_if_model의 단가로 다시 계산. saving_usd = cost_usd − what_if.cost_usd. 단가표에 없는 모델이면 HTTP 400 {"error": "unknown model", "model", "known": [...]}. what_if_model이 없으면 what_if도 없다.

4. 워크플로우 파일 규칙

{
  "name": "75 IT helpdesk (my)",
  "nodes": [ ... ],
  "connections": { ... },
  "settings": { "executionOrder": "v1" }
}
  • 파일 하나에 워크플로우 하나. 노드 name은 영어로, 겹치지 않게. 노드마다 type, typeVersion, position, parameters.
  • API 키를 JSON에 쓰지 않는다. OpenAI는 Credential id로만 부른다 (아래 5절의 predefinedCredentialType).
  • 웹훅은 POST, responseMode: "responseNode", 결과는 Respond to Webhook 노드로.
  • 요청 본문은 $json.body.question처럼 body 아래. 다른 노드 뒤에서는 $('Question webhook').first().json.body.

5. 검증된 노드 (n8n 2.42.6에서 실행 확인)

토큰이 보이는 LLM 호출 — HTTP Request로 OpenAI를 직접

AI Agent 노드는 답 글자만 돌려주고 토큰 수를 주지 않는다. 오늘은 HTTP Request로 OpenAI를 불러 응답의 usage를 쓴다.

{"name": "Answer", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 300],
 "parameters": {"method": "POST", "url": "https://api.openai.com/v1/chat/completions",
   "authentication": "predefinedCredentialType", "nodeCredentialType": "openAiApi",
   "sendBody": true, "specifyBody": "json",
   "jsonBody": "={{ JSON.stringify({ model: 'gpt-4.1-mini', temperature: 0, max_completion_tokens: 200, messages: [{ role: 'system', content: \"You are ...\" }, { role: 'user', content: '질문: ' + $('Question webhook').first().json.body.question }] }) }}",
   "options": {"timeout": 60000}},
 "credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
  • 결과: $json.choices[0].message.content(답 글자), $json.usage.prompt_tokens, $json.usage.completion_tokens.
  • max_completion_tokens: 답(출력 토큰)의 상한. 너무 작으면 답이 잘린다. gpt-5.4-nano는 max_tokens를 받지 않는다 (HTTP 400 Unsupported parameter). 두 모델 모두 max_completion_tokens로 쓴다 (check가 max_tokens를 찾아 알려 줌).
  • 노드 이름을 단계 이름과 맞추면(Classify · Answer · Review) 마지막 Code에서 $('Answer').first().json.usage로 모으기 쉽다.

단가 · 예산 · 한도 (HTTP)

{"name": "Get prices", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 300], "parameters": {"url": "http://data-api:8800/prices", "options": {}}}
{"name": "Get budget", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 300], "parameters": {"url": "http://data-api:8800/budget", "options": {}}}
{"name": "Recent requests", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 300],
 "parameters": {"url": "=http://data-api:8800/usage/user/{{ encodeURIComponent($('Question webhook').first().json.body.user ?? '') }}?window=60", "options": {}}}

단계마다 비용 (Code)

const prices = $('Get prices').first().json.models;
const p = prices[s.model] ?? { input: 0, output: 0 };
row.cost_usd = Math.round((row.prompt_tokens * p.input + row.completion_tokens * p.output) / 1e6 * 1e8) / 1e8;

사용량 기록 (HTTP POST, 항목 여러 개)

{"name": "Record usage", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2000, 300],
 "parameters": {"method": "POST", "url": "http://data-api:8800/usage", "sendBody": true, "specifyBody": "json",
   "jsonBody": "={{ JSON.stringify({ items: Object.entries($json.usage.by_step).map(([step, row]) => ({ request_id: $execution.id, user: $('Question webhook').first().json.body.user ?? '', step, model: row.model, prompt_tokens: row.prompt_tokens, completion_tokens: row.completion_tokens, cost_usd: row.cost_usd, route: $json.route })) }) }}",
   "options": {"response": {"response": {"neverError": true}}}}}
  • $execution.id: 이번 실행의 번호 (한 요청의 여러 LLM 호출을 묶는 request_id).
  • neverError면 기록 API의 4xx · 5xx 응답도 성공 쪽으로 온다. 시간 초과 · 연결 실패는 노드 설정 onError로 다룬다 (노드 바로 아래, parameters 밖).
  • 기록 노드 다음에는 결과를 다시 꺼내 응답한다: return [{ json: $('Build result').first().json }];

429 응답

{"name": "Too many", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [1100, 820],
 "parameters": {"respondWith": "json", "responseBody": "={{ JSON.stringify({ route: '잠시 후', answer: '...', retry_after: 60, llm_calls: 0, cost_usd: 0 }) }}", "options": {"responseCode": 429}}}

그 밖 (72~74일차와 같음)

  • Webhook · If · Code · Search FAQ(HTTP /faq, 쿼리 q) · Get ticket(HTTP /tickets, neverError) · Respond to Webhook.
  • 지나가지 않았을 수 있는 노드는 $('노드').isExecuted로 확인한 뒤 읽는다 (규칙으로 분류를 건너뛸 때): const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };
  • 오류 응답: "responseBody": "={{ $json }}", "options": {"responseCode": 400}. Code 노드는 HTTP 상태를 정하지 못하므로 If로 갈라 Respond로.

6. 연결 (connections)

{
  "Within budget?": {"main": [[{"node": "Rule classify", "type": "main", "index": 0}], [{"node": "Over budget", "type": "main", "index": 0}]]},
  "Answer": {"main": [[{"node": "Build result", "type": "main", "index": 0}]]}
}
  • 키는 보내는 노드 이름, 안의 node는 받는 노드 이름. If의 main은 [ [true 쪽], [false 쪽] ].
  • HTTP로 부르는 LLM은 보통 노드라 main으로 잇는다 (ai_languageModel 아님).

7. 표현식 주의

  • 표현식은 =로 시작한다. JSON.stringify({ ... }) 안의 글자에 '가 있으면 깨지기 쉽다 — 지시문은 "로 감싸거나 앞 노드(Set)에 두고 읽는다. {{ }} 안에서 객체를 만들 때 }}가 붙지 않게 띄어 쓴다.
  • 숫자 계산은 Code에서. 반올림: Math.round(x * 1e8) / 1e8 (보고서는 1e6).

8. 테스트 파일 (cases/*.json)

{
  "webhook": "my-cost",
  "before": [{"url": "http://localhost:8800/usage-clear", "body": {}}],
  "cases": [
    {"name": "VPN · 비용 상한", "request": "requests/faq-vpn.json",
     "expect": {"equals": {"llm_calls": 1}, "range": {"cost_usd": [0.000001, 0.0002]}, "contains": {"answer": ["인증서"]}}},
    {"name": "요약 확인", "url": "http://localhost:8800/usage/summary", "body": {}, "expect": {"equals": {"requests": 1}}}
  ]
}
  • range: 숫자가 [최소, 최대] 안. url: 웹훅 대신 그 주소에 POST (데이터 API 확인용).
  • equals · contains · not_contains · status · max_seconds, before · after는 72~74일차와 같다. 점이 들어간 키(gpt-4.1-mini)는 경로로 못 읽으니 contains로 JSON 글자를 찾는다.
  • 줄 끝의 · 자동 답변 · 1 call · $0.000156은 결과의 route · llm_calls · cost_usd다.
  • uv run tools/flow.py test <파일> -v: 케이스마다 요청 · 응답 JSON. --webhook nano-answer: 같은 파일을 다른 웹훅에. 모든 명령이 -v를 받는다 (74일차와 같음).
  • uv run tools/flow.py usage [--clear] · budget [--daily 0.001] [--per-min 2] [--chars 2000] [--reset] · fault usage error|slow|ok (사람이).
  • 테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다. 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.

9. 하지 말 것

  • tools/, data-api/, requests/, reference/, 이 파일(AGENTS.md)을 고치지 않는다 (요청받은 경우만). 단가(prices.json) · 예산(budget.json)은 사람이 정한다.
  • 워크플로우 안에서 /budget, /usage-clear, /admin/fault에 POST 하지 않는다 (예산 · 기록 비우기 · 고장 스위치는 사람이).
  • 비용을 줄이려고 답의 내용 검사(테스트의 contains)를 없애지 않는다. 단가를 워크플로우에 숫자로 쓰지 않는다.
  • n8n 컨테이너를 지우거나 docker compose down을 하지 않는다. ~/day65-n8n(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
  • .env나 API 키를 읽어서 출력하지 않는다.