Engineering · AI Agents
지난 수십 년간 좋은 소프트웨어 공학은 모호함을 제거하는 일이었다. 그런데 에이전트는 모호함 위에서 작동한다. 구글 딥마인드의 한 발표가 짚은 다섯 가지 사고 충돌을 따라가 본다.
소프트웨어 엔지니어가 오래 갈고닦는 직업적 본능이 하나 있다. 모호함을 없애는 것이다. 인터페이스를 엄격하게 정의하고, 타입을 강제하고, "입력 A에 코드 B를 넣으면 항상 출력 C가 나온다"는 등식을 보장한다. 이 등식이 깨지면 그것은 버그이고, 버그는 고쳐야 한다. 좋은 코드란 예측 가능한 코드다.
구글 딥마인드(Google DeepMind)에서 개발자 대상 기술 전파를 맡고 있는 필립 슈미트(Philipp Schmid)는 최근 한 발표와 글에서 흥미로운 역설을 제기했다. 인공지능 에이전트(AI agent)를 만들 때, 경력이 짧은 주니어 엔지니어가 오히려 시니어보다 작동하는 결과물을 더 빨리 내놓는 경우가 잦다는 것이다.
경력이 쌓일수록 엔지니어는 에이전트의 추론 능력과 지시 수행 능력을 덜 신뢰하는 경향이 있다. 그래서 모델과 싸우고, 확률적인 성질을 코드로 "없애려" 든다. Philipp Schmid, "Why (Senior) Engineers Struggle to Build AI Agents" (2025. 11.)
여기서 핵심 단어는 확률적(probabilistic)이다. 전통적 소프트웨어는 결정론적(deterministic)이다. 같은 입력이면 항상 같은 출력이 나온다. 반면 대규모 언어 모델(LLM, Large Language Model)을 두뇌로 삼은 에이전트는 같은 입력에도 매번 조금씩 다른 경로를 밟고, 다른 결과를 낼 수 있다. 시니어가 더 잘 아는 그 모든 "올바른 공학 습관"이, 확률적 시스템 앞에서는 도리어 발목을 잡는다.
슈미트는 이 충돌을 운전에 빗댄다. 전통적 소프트웨어를 만들 때 우리는 교통 관제원이었다. 도로와 신호등과 법규를 직접 소유하고, 데이터가 정확히 어디로 어떻게 흐를지 결정했다. 그런데 에이전트를 다룰 때 우리는 배차원에 가깝다. 운전자(LLM)에게 "런던으로 가달라"고 목적지만 일러주는 사람이다. 그 운전자는 지름길로 갈 수도, 길을 잃을 수도, "이게 더 빨라 보여서"라며 인도로 차를 몰 수도 있다.
관제원은 차가 어느 차선으로, 어떤 속도로, 어떤 신호에 멈추는지를 전부 통제한다. 경로의 모든 칸이 그의 손안에 있다. 배차원은 다르다. "이 손님을 강남역까지 모셔다 드리세요"라고만 지시한다. 어느 길로 갈지는 운전자가 그때그때 판단한다. 막히면 돌아가고, 사고가 나면 우회한다.
에이전트를 만든다는 것은 관제원에서 배차원으로 역할을 바꾸는 일이다. 목적지(목표)는 우리가 정하지만, 거기 도달하는 정확한 단계는 더 이상 우리가 정하지 않는다. 코딩 도구를 써본 사람이라면 익숙할 것이다. 중간에 가끔 이상한 짓을 하지만, 결국 원하던 결과에는 도달하는 그 경험 말이다.
아래 그림은 두 패러다임의 차이를 한 장으로 보여준다.
아래는 슈미트가 든 다섯 가지 사례다. 전통적 공학 습관이 에이전트 시대의 현실과 정면으로 부딪치는 지점들이다. 각각은 "왜 우리가 무심코 잘못된 방식을 택하는가"라는 함정과, "그 대신 무엇을 해야 하는가"라는 처방의 짝으로 이루어져 있다.
Text is the New State
전통적 공학에서 우리는 세계를 데이터 구조로 모델링한다. 스키마를 정의하고, 인터페이스를 만들고, 타입을 엄격하게 박는다. 이 방식이 안전하게 느껴지는 이유는 예측 가능하기 때문이다. 그래서 우리는 에이전트도 본능적으로 이 상자 안에 욱여넣으려 한다.
함정 현실의 의도·선호·설정은 이진적이거나 구조적인 경우가 드물다. 사람이 입력하는 것은 구조화된 필드(이산값)가 아니라 자연어(연속값)다.
예를 들어 사용자가 심층 조사 계획을 검토하고 이렇게 말한다고 하자. "이 계획 좋네요. 다만 미국 시장에 집중해 주세요." 결정론적 시스템은 이 문장을 승인 여부: 참/거짓이라는 칸 하나로 욱여넣는다. 그 순간, "미국 시장에 집중"이라는 핵심 맥락은 통째로 잘려나간다. 슈미트는 이를 두고 맥락을 "절제(lobotomize)해버린다"고 표현한다.
{
"plan_id": "123",
"status": "APPROVED" // "미국 시장" 지시는 어디로?
}
{
"plan_id": "123",
"text": "계획 좋네요. 다만 미국 시장에 집중해 주세요."
}
텍스트를 그대로 보존하면, 다음 단계의 에이전트가 그 피드백을 읽고("승인하되, 미국 시장 집중") 행동을 동적으로 조정할 수 있다. 사용자 선호도 마찬가지다. 결정론적 시스템은 섭씨 사용: 참이라는 플래그를 저장한다. 반면 에이전트 시스템은 "날씨는 섭씨로 보지만, 요리할 때는 화씨가 편해요"라는 문장을 저장한다. 그러면 에이전트는 작업의 종류에 따라 단위를 알아서 바꾼다.
처방 boolean의 안락함을 버리고 의미(semantic meaning) 자체를 상태로 삼아라. 이제 애플리케이션의 상태를 담는 그릇은 잘 정의된 데이터 구조가 아니라 텍스트(때로는 이미지·음성·영상까지 포함하는 맥락)다.
분식집 키오스크에는 "곱빼기"와 "보통" 버튼이 있다. 그런데 손님이 "면은 곱빼기인데 국물은 좀 적게, 그리고 파는 빼주세요"라고 한다면? 체크박스로는 담을 수 없다. 결국 주방으로 가는 주문서에 손으로 메모를 적게 된다.
에이전트에게 그 메모지가 곧 데이터다. 미리 만들어 둔 칸에 손님을 맞추는 대신, 손님의 말을 그대로 적어 두고 주방(다음 단계)이 알아서 해석하게 한다. 칸을 늘리는 게 아니라, 칸을 없애는 발상의 전환이다.
Hand over Control
마이크로서비스 구조에서 사용자의 의도는 곧 경로(route)에 대응한다. "구독을 해지하겠다"는 의도는 POST /subscription/cancel이라는 종착점으로 연결된다. 엔지니어는 이 경로를 직관적으로 손수 코딩한다. 그런데 에이전트는 다르다. 자연어로 된 단일 진입점이 있고, 두뇌(LLM)가 가용한 도구·입력·지침을 종합해 제어 흐름을 스스로 결정한다.
함정 우리는 그 흐름을 에이전트 안에 하드코딩하려 든다. 하지만 실제 상호작용은 직선으로 흐르지 않는다. 루프를 돌고, 되돌아가고, 방향을 튼다. 해지하려던 사용자가 결국 갱신에 동의하기도 한다.
예전 방식이라면 이 모든 분기와 예외를 일일이 상태 기계(state machine)로 모델링해야 했다. 사용자가 보일 수 있는 모든 변심과 특수 상황을 미리 그려두는 일은 사실상 불가능에 가깝다.
처방 전체 맥락을 바탕으로 현재 의도를 이해하도록 에이전트를 신뢰하라. 모든 예외 경우를 하드코딩하고 있다면, 그것은 더 이상 AI 에이전트를 만드는 것이 아니다. 통제권을 모델에 넘긴다는 것은, 우리가 더는 순수하게 결정론적인 환경에서 일하지 않는다는 사실을 받아들이는 일이다.
Errors are just Inputs
전통적 소프트웨어에서 API 호출이 실패하거나 변수가 비어 있으면, 우리는 예외(exception)를 던진다. 프로그램이 즉시 멈추기를 바란다. 그래야 버그를 빨리 찾아 고칠 수 있기 때문이다. 예전에는 이 방식이 합리적이었다. HTTP 요청은 값쌌다. 상품 검색 한 번이 실패하면 그냥 요청을 다시 보내면 됐다. 다시 해도 손해가 크지 않았다.
함정 에이전트는 사정이 다르다. 한 번의 실행이 5분, 15분씩 걸리고 비용도 만만치 않다(슈미트는 한 번에 0.5달러가 들 수 있다고 예시한다). 5단계 중 4단계까지 와서 입력 하나가 잘못돼 실패했다고 전체 실행을 처음부터 다시 돌린다면, 그동안 쌓아 온 맥락도 날아가고 연산 비용도 다시 치러야 한다. 이는 받아들이기 어렵다.
처방 오류를 또 하나의 입력으로 취급하라. 멈춰 세우는 대신, 오류를 붙잡아 에이전트에게 다시 먹이고 복구를 시도하게 한다. 슈미트는 이것이 Go 언어가 이미 하는 방식과 닮았다고 짚는다. Go에서 함수 호출은 값을 반환할 수도, 오류를 반환할 수도 있으며, 둘은 거의 대등하게 다뤄진다. 에이전트도 그렇게 다뤄야 한다.
내비게이션을 따라 운전하는데 앞길이 갑자기 통제됐다고 하자. 내비가 "경로 실패"라며 차를 멈춰 세우고 출발지로 돌아가라고 하지는 않는다. "전방 통제"라는 새 정보를 입력으로 받아들여, 그 자리에서 우회로를 다시 계산한다.
에이전트의 오류 처리도 이래야 한다. 실패는 여정의 끝이 아니라, 다음 판단에 쓰일 새로운 정보다. 멈추지 말고, 그 정보를 들고 앞으로 나아가도록 설계하는 것이 핵심이다.
From Unit Tests to Evals
테스트 주도 개발(TDD, Test-Driven Development)은 더 견고한 코드를 짜는 데 큰 도움을 준다. 입력 A를 넣으면 출력 C가 나온다는 것을 단언(assert)하고, 그 단언이 깨지지 않도록 코드를 다듬는다. 그런데 에이전트는 유닛 테스트로 검증할 수 없다. 엔지니어가 확률적 시스템에서 이진적 정답을 찾느라 몇 주를 허비하기도 한다.
함정 창의적이거나 추론이 필요한 작업에는 이진적 단언을 쓸 수 없다. "이 이메일을 요약하라"는 작업에는 유효한 정답이 무한히 많다. 그렇다고 LLM을 가짜(mock)로 대체해 테스트하면, 그것은 에이전트를 테스트하는 게 아니라 우리가 짜놓은 문자열 결합을 테스트하는 꼴이 된다.
처방 테스트 대신 평가(eval)로 옮겨가라. 추론은 유닛 테스트할 수 없으니, 신뢰성과 품질을 검증하고 중간 단계를 추적한다. 슈미트는 세 축을 제시한다.
에이전트는 같은 입력에도 매번 같은 결과를 보장하지 않는다. 그래서 한 번 성공했는지가 아니라, 여러 번 돌렸을 때 몇 번 성공하는지를 본다. 슈미트는 이를 Pass^k(여러 번 시도했을 때의 통과율) 개념으로 설명한다. 고객 응대 에이전트가 같은 질문에 열 번 중 한 번만 제대로 답한다면, 그것은 프로덕션에 올릴 물건이 아니다.
결과가 주관적인 작업에서는 정성적 평가가 필요하다. 답이 도움이 되는가? 어조가 적절한가? 요약이 정확한가? 이런 판단을 자동화하는 방법이 LLM as a Judge, 즉 또 다른 언어 모델을 심사위원으로 세우는 것이다. 사람 전문가의 평가와 병행하면 신뢰도가 높아진다.
최종 답만 확인하는 것으로는 부족하다. 중간 단계를 추적(tracing)해야 한다. 답하기 전에 정말 지식 베이스를 검색했는가? 한 사용자에게는 추가 조사 네 단계를 더 밟고, 다른 사용자에게는 토큰을 조금 더 쓰더라도, 우리가 측정하고 싶은 것은 결국 결과의 성공 여부다.
지원자가 면접에서 답 하나를 완벽하게 말했다고 바로 채용하지는 않는다. 진짜 궁금한 것은 "이 사람이 열 번 중 몇 번 좋은 결정을 내리는가", 그리고 "어떻게 그 결론에 이르렀는가"이다. 한 번의 정답보다 일관된 성공률과 사고 과정이 중요하다.
에이전트 평가도 같다. 정답 한 번을 단언(assert)하는 대신, 반복 통과율과 추론 경로를 함께 본다. 변동을 없애려 애쓰기보다, 충분히 믿을 만한지를 가늠하는 일이다.
Agents Evolve, APIs Don't
지난날 우리는 응용 프로그래밍 인터페이스(API, Application Programming Interface)를 사람 개발자를 위해 설계했다. 암묵적 맥락에 기대고, "깔끔한" 인터페이스를 추구했다. 백엔드를 다뤄본 사람이라면 delete_item 같은 종착점이 너무나 자명하게 느껴질 것이다. 그런데 결정적 차이가 하나 있다. 사람은 맥락을 추론하지만, 에이전트는 문자 그대로 받아들인다. 에이전트는 직역가(literalist)다. 식별자(ID) 형식이 모호하면, 에이전트는 그것을 환각으로 지어낸다.
함정 우리는 "사람 등급" API를 만든다. id라는 변수가 우리에겐 당연히 사용자의 범용 고유 식별자(UUID, Universally Unique Identifier)지만, 에이전트는 그 배경 지식이 없다. 에이전트는 함수의 스키마와 설명 문서(docstring), 도구 정의만 본다. 코드를 직접 보지 못하고, 우리가 그 API를 만들며 쌓은 수년치 맥락도 알지 못한다. 그래서 get_user(id)에 엉뚱하게 이메일이나 이름을 넣을 수 있다.
delete_item(id)
# id가 정수? UUID? 못 찾으면 무슨 일이?
delete_item_by_uuid(uuid: str) # "아이템을 삭제한다. 못 찾으면 서술적 # 오류 문자열을 반환한다."
처방 에이전트를 위해 쓰인 도구를 만들어라. 장황하더라도 "바보 방지(idiot-proof)" 수준으로 의미를 명시한 타이핑(가령 email 대신 user_email_address)과, 맥락 역할을 하는 서술적 설명 문서가 필요하다. 사람 개발자의 전문성이나 API를 만든 사람의 사정을 전제하지 않는, 스스로를 설명하는(self-documenting) 인터페이스다.
여기서 한 가지 반전이 있다. API는 본래 개발자와 맺은 약속이다. 우리는 그 약속에 기대어 코드를 짜놓고 떠난다. 그래서 get_user_by_id(id)를 get_user_by_email(email)로 바꾸면 약속이 깨지고, 그에 의존하던 모든 것이 즉시 무너진다. 그런데 에이전트는 다르다. 바뀐 도구 정의를 읽고 그 자리에서 적응한다. 슈미트는 이를 적시 적응(JIT, Just-In-Time adaptation)이라 부른다. 사람은 깨지지만, 에이전트는 다시 읽고 맞춰간다.
오래 일한 직원에게는 "그 서류 처리해줘"라고만 해도 통한다. 어느 서랍에서, 어떤 양식으로, 누구에게 보내야 하는지 몸으로 안다. 하지만 첫 출근한 직원에게는 그렇게 말할 수 없다. "왼쪽 서랍의 노란 양식을 꺼내, 이름과 사번을 적고, 3층 총무팀에 제출. 양식이 없으면 비품실에 요청"이라고 하나하나 적어줘야 한다.
에이전트는 매번 새로 출근한 직원과 같다. 우리 머릿속에만 있는 맥락을 도구 설명서에 글로 다 풀어 적어야 한다. 다만 이 신입에게는 특별한 재주가 하나 있다. 매뉴얼이 갱신되면, 다시 읽고 즉시 새 절차대로 일한다.
결정론적 시스템에서 확률적 에이전트로 넘어가는 일은 불편하다. 확실성을 의미적 유연성과 맞바꾸는 일이기 때문이다. 우리는 더 이상 정확한 실행 경로를 알지도, 소유하지도 못한다. 제어 흐름을 비결정론적 모델에 넘기고, 애플리케이션 상태를 자연어로 저장한다. 엄격한 인터페이스로 단련된 머리에는 이 모든 것이 잘못된 일처럼 느껴진다.
하지만 에이전트를 결정론적 상자에 욱여넣으려는 시도는, 애초에 에이전트를 쓰는 목적 자체를 무너뜨린다. 확률은 코드로 없앨 수 없다. 평가와 자기수정으로 관리해야 한다. 그렇다고 "신뢰"가 방치를 뜻하지는 않는다. 중간 지점을 찾아야 한다. 슈미트가 다른 글에서 강조하듯, 언제 에이전트 대신 정해진 워크플로우(workflow)를 써야 하는지를 아는 것도 그 균형의 일부다.
슈미트는 발표를 여섯 개의 원칙으로 갈무리한다.
마지막 원칙, "버릴 것을 전제로 만들어라(build to delete)"에서 슈미트는 쓰라린 교훈(The Bitter Lesson)을 언급한다. 이 표현은 본래 강화학습의 선구자 리처드 서튼(Richard Sutton)이 2019년에 쓴 짧은 에세이의 제목이다. 서튼의 원래 주장은 "지난 70년의 인공지능 연구에서 얻은 가장 큰 교훈은, 연산을 활용하는 일반적 방법이 인간이 손수 설계한 지식 기반 방법을 결국 압도한다는 것"이었다. 사람의 영리한 설계는 단기적으로 앞서 보여도, 무어의 법칙을 타고 늘어나는 연산량 앞에서는 결국 밀린다는 이야기다.
슈미트는 이 교훈을 소프트웨어 개발의 관점으로 끌어와 적용한다. 모델이 계속 강해지는 한, 오늘 우리가 모델의 약점을 메우려 정성껏 짠 보조 코드와 우회 장치는 머지않아 불필요해진다는 것이다. 그러니 코드에 과하게 애착을 두지 말고, 다시 만들 것을 전제로 가볍게 짓는 편이 낫다. 엄밀히 말하면 이는 서튼의 원문이 펼친 논지 그 자체라기보다, 그 정신을 엔지니어의 일상으로 옮겨온 해석에 가깝다.
에이전트는 앞으로도 예상치 못한 방식으로 숱하게 실패할 것이다. 그러나 방향은 분명하다. 모호함을 코드로 없애려는 노력을 멈추고, 그 모호함을 견뎌낼 만큼 회복력 있는 시스템을 설계하는 쪽으로. 시니어 엔지니어가 에이전트 앞에서 머뭇거리는 이유는 능력이 부족해서가 아니다. 오히려 너무 잘 단련된 그 본능이, 새로운 규칙의 게임에서는 한 번 내려놓아야 하는 짐이기 때문이다.