내 AI 에이전트가 왜 계속 잘못된 클라이언트의 Salesforce에 데이터를 작성했는가
요약
AI 에이전트가 여러 클라이언트의 계정을 혼동하여 잘못된 데이터에 접근하는 문제를 다룹니다. 모델의 메모리에 의존하는 대신, 액션 범위를 좁히고 연결 정보를 명확히 관리하는 설계의 중요성을 강조합니다.
핵심 포인트
- LLM의 메모리에 작업 대상(계정)을 기억하도록 신뢰해서는 안 됨
- 수천 개의 액션 중 모델이 선택할 후보를 미리 좁히는 설계가 필수적
- 메타데이터와 키워드 검색을 활용해 컨텍스트 윈도우 부하를 줄여야 함
- 연결(Connection) 정보를 활용한 컨텍스트 제한이 에이전트 정확도의 핵심
한 사용자가 작업 도중 약간 당황한 기색으로 저에게 메시지를 보냈습니다. 에이전트가 잘못된 클라이언트의 Salesforce에 Apex trigger를 작성하고 있었기 때문입니다.
그는 여러 명의 클라이언트를 관리하며, 각 클라이언트마다 하나의 Salesforce 계정을 가지고 있습니다. 한 세션에서는 특정 연결(connection)을 선택하고 커스텀 필드(custom fields)를 요청합니다. 다음 세션에서는 다른 연결을 선택하고 LWC component를 요청하는데, 에이전트는 다시 첫 번째 계정으로 돌아가 그곳에서 작업을 시작해 버리는 것입니다. 그는 작업을 중단하고 클라이언트의 라이브 org(live org)에 영향을 준 부분을 수동으로 되돌려야 했습니다.
실제 행동을 취하는 에이전트에 대해 모두가 잘못 알고 있는 점이 있습니다. 사람들은 통합(integrations)을 연결하는 것이 어려운 부분이라고 생각합니다. 그렇지 않습니다. API를 래핑(wrapping)하는 것은 지루한 일이지 어려운 일이 아닙니다. 진짜 어려운 부분은 모델이 매번 두 가지를 정확히 수행하는 것입니다: 바로 _어떤 행동(which action)_을 할 것인가, 그리고 _어떤 계정(which account)_을 대상으로 할 것인가입니다. 그리고 두 번째 문제에 대한 해결책은 더 나은 프롬프트(prompt)를 만드는 것이 아니었습니다. 메모리(memory)를 가진 LLM은 자신이 누구를 위해 작업하고 있는지 기억할 것이라고 신뢰해서는 안 된다는 사실을 깨닫는 것이었습니다.
이 시스템의 실체
그렇다면 이 시스템은 무엇일까요? 이것은 문장을 입력하면 LLM이 수백 개의 앱 중 하나의 행동을 선택하여 바로 실행하는 CLI(Command Line Interface)입니다. 이메일을 보내고, Salesforce 필드를 생성하고, QuickBooks 인보이스를 가져오고, 문서를 작성합니다. UI를 클릭하거나 확인 단계를 거치지 않고, 당신의 실제 계정에서 실제 행동을 수행합니다. 원하는 것을 말하면 시스템이 가서 실행합니다. 그 부분을 작동하게 만드는 데는 오후 한나절이 걸렸습니다. 하지만 잘못된 행동을 하지 않게 만드는 데는 몇 달이 걸렸으며, 그 기간의 대부분은 앞서 언급한 두 가지 질문을 해결하는 데 사용되었습니다.
어떤 행동 (Which action)
가장 명백한 문제부터 시작해 보겠습니다. 당신에게는 700개 이상의 앱이 있고, 각 앱은 수많은 액션 (action)들을 가지고 있습니다. Gmail 하나만 보더라도 보내기, 초안 작성, 검색, 라벨 지정, 답장 등 목록은 끝이 없습니다. 이를 모든 앱에 곱하면 수천 개의 가능한 액션들을 마주하게 됩니다. 순진한 생각은 이 모든 것을 모델에게 넘겨주며 하나를 고르라고 말하는 것입니다. 하지만 그것은 작동하지 않습니다. 사용자가 문장을 채 마치기도 전에 컨텍스트 윈도우 (context window)를 다 써버리게 되며, 설령 들어간다 하더라도 옵션이 많아질수록 모델의 선택 능력은 좋아지는 것이 아니라 오히려 나빠집니다.
따라서 진짜 질문은 "모델이 액션을 고를 수 있는가"가 아닙니다. "모델이 선택을 하기 전에 어떻게 후보를 몇 가지로 줄일 것인가"입니다.
이를 좁히는 두 가지 저렴한 방법이 있습니다. 사용자가 앱 이름을 언급한다면 이미 끝난 것입니다. 직접 찾아보면 되니까요. 만약 언급하지 않는다면, 모든 앱은 그것이 무엇을 위한 것인지 설명하는 약간의 메타데이터 (metadata)를 가지고 있으며, 모델은 이를 키워드 검색 (keyword-search) 하여 가능성 높은 앱을 찾을 수 있습니다. "이메일"에 관한 요청은 메일 앱들을 불러오고 나머지 690개의 앱은 무시합니다.
하지만 진짜 범위를 좁혀주는 것은 대부분의 사람들이 건너뛰는 것, 즉 연결 (connections)에서 옵니다. 에이전트가 앱을 건드리기 전에 사용자는 앱을 연결해야 하며, 이는 권한을 부여 (authorize) 해야 함을 의미하고, 우리는 그 연결 정보를 암호화하여 저장합니다. 따라서 우리는 어느 시점에서든 사용자가 실제로 연결해 둔 정확한 앱 세트를 알고 있으며, 이는 보통 700개가 아니라 5개 또는 10개 정도입니다.
이제 "이메일 보내줘"라는 요청은 존재하는 모든 메일 제공자를 대상으로 하는 검색이 아니게 됩니다. 만약 사용자의 기록에 Gmail 연결이 있고 다른 메일 앱이 없다면, 추측할 필요도 없이 그것이 정답입니다. 요청에 부합하는 연결이 전혀 없다면, 우리는 환각 (hallucinate)을 일으키지 않고 그저 먼저 무언가를 연결해 달라고 요청합니다. 권한 부여 그래프 (authorization graph)가 대부분의 범위를 무료로 좁혀줍니다. 사용자가 연결하는 날 이미 무엇을 사용하는지 우리에게 말해주었기 때문입니다.
사용자가 연결하는 앱이 두 개 이상인 경우, 예를 들어 Gmail 계정이 두 개이거나 Salesforce 오거나이제가 세 개일 경우를 제외하고는 대부분의 범위가 무료로 좁혀집니다. 사용자가 이미 무엇을 사용하는지 우리에게 말해주었기 때문입니다.
모델 주변의 의식(The ritual around the model)
에이전트가 앱을 알게 되면, 그 안에서 액션을 선택하는 것은 고정된 의식이 됩니다. 이 스킬은 에이전트가 항상 같은 순서로, 절대로 즉흥적으로 행동하지 않도록 구속합니다. 통합 패키지가 아직 없다면 설치하고, 이것들은 앱별 자체 패키지이기 때문에 필요한 것만 게으르게 다운로드합니다. 그 패키지의 매니페스트(manifest)를 읽습니다. 노출되는 액션 목록과 그 설명을 나열합니다. 요청에 맞는 것을 고릅니다. 그 액션을 호출하는 데 필요한 속성(prop)들을 조사합니다. 속성을 채웁니다. 그리고 호출합니다.
매번 같은 춤입니다. 이 의식의 요점은 비결정적인 것 주변을 결정론으로 감싸는 것입니다. 모델이 여전히 선택을 하지만, 당신이 결정론적으로 만들 수 없는 부분이지만, 움직이지 않는 레일 안에서 선택을 합니다. 매니페스트를 건너뛰고 액션 이름을 추측할 수는 없습니다. 왜냐하면 에이전트가 보는 모든 액션은 매니페스트가 방금 넘겨준 실제 액션들뿐이기 때문입니다.
액션이 없는 경우(When there's no action for it)
때로는 사용자가 원하는 기능에 대한 액션 (Action)을 아직 구축하지 못했을 수도 있습니다. 이 경우 앱은 실패하는 대신, 동일한 연결 (Connection) 상에서 로우 API 호출 (raw API call)을 노출합니다. 따라서 미리 구축된 액션이 적합한 것이 없다면, 에이전트는 사용자가 이미 권한을 부여한 자격 증명 (Credentials)을 사용하여 직접 REST 호출을 수행할 수 있습니다. 통합 (Integration) 기능이 누락되었다고 해서 작업이 완전히 실패하는 것이 아니라, API를 직접 호출하는 방식으로 기능이 저하 (Degrade)되는 것입니다. 이는 우리가 미처 코드로 구현하지 못한 방대한 기능들 (Long tail of stuff)이라 할지라도, 기반이 되는 앱에 엔드포인트 (Endpoint)가 있다면 여전히 작동함을 의미합니다.
두 가지 모드, 그리고 그 사이의 트레이드오프 (Tradeoff)
"확인 단계가 없다"는 말에 움찔하실 수도 있습니다. 하지만 그것은 실행할 수 있는 두 가지 방법 중 하나일 뿐입니다.
제가 설명해 온 방식은 직접 모드 (Direct mode)입니다. 원하는 것을 말하면 질문 없이 그냥 실행합니다. 사용자는 이미 자신이 무엇을 원하는지 우리에게 말했으므로, 모든 개별 액션 앞에 예/아니오를 붙이는 것은 이런 도구를 사용하는 근본적인 이유를 없애버립니다. 이것은 빠르고, 방해받지 않는 모드입니다.
그리고 리스크가 더 큰 상황을 위한 플랜 모드 (Plan mode)가 있습니다. 에이전트는 먼저 모든 단계를 계획하고, 어떤 것에도 손을 대기 전에 사용자에게 보여줍니다. 따라서 사용자는 에이전트가 정확히 무엇을 하려는지 읽고 승인할 수 있습니다. 게다가, 모든 파괴적인 단계 (Destructive step)는 실행 직전에 다시 한번 확인을 요청하므로, 일이 벌어진 후가 아니라 중요한 순간에 자신이 무엇을 하고 있는지 알 수 있습니다. 더 느리지만, 사용자가 실제로 루프 (Loop) 안에 있게 됩니다.
| 직접 모드 (Direct mode) | 플랜 모드 (Plan mode) | |
|---|---|---|
| 속도 | 즉시 실행 | 더 느림, 먼저 검토 필요 |
| ... |
직접 모드에서는 허가를 구하는 방식에서 안전성을 확보할 수 없습니다. 왜냐하면 허가 과정 자체가 없기 때문입니다. 안전성은 가드레일 (Rails)에서 옵니다. 그라운딩 의식 (Grounding ritual)이 하나의 가드레일입니다. 그리고 실제로 중요했던 또 다른 가드레일은 에이전트가 신원 (Identity)을 처리하는 방식입니다. 제가 이 부분으로 넘어가려는 이유도 바로 이것 때문인데, 바로 여기서 제가 큰 코를 다쳤기 때문입니다.
어떤 계정인가 (Which account)
다시 잘못된 조직 (Org)에서 Apex 트리거 (Apex trigger)를 실행해 패닉에 빠진 사용자 이야기로 돌아가 봅시다.
에이전트는 세션(session) 전반에 걸쳐 메모리(memory)를 유지합니다. 이는 기능의 일부이며, 에이전트가 컨텍스트(context)와 사용자가 이전에 수행한 작업을 기억하는 방식입니다. 그리고 모든 연결(connection)은 데이터베이스에 영구적인 ID, 즉 우리가 다시 불러올 때 사용하는 안정적인 connectionId를 가지고 있습니다. 이 두 가지 요소는 각각 개별적으로는 합리적입니다. 하지만 이들이 결합되면서 버그를 일으켰습니다.
내부적으로 실제로 어떤 일이 일어났는지 설명하겠습니다. 한 세션에서 사용자가 클라이언트 A를 작업했고, A의 connectionId가 메모리에 저장되었습니다. 다음 세션에서 사용자는 명시적으로 클라이언트 B의 연결을 첨부하고 LWC 작업을 요청했습니다. 하지만 메모리에는 여전히 이전의 A의 connectionId가 남아 있었고, 모델은 모델이 하는 방식대로 메모리에서 그 ID를 다시 꺼내어 사용했습니다. 때로는 사용자가 첨부한 연결과 모델이 기억하고 있던 연결 모두에서 실행되기도 했습니다. 안정적인 식별자(identifier)가 메모리를 통해 세션 경계(session boundary)를 넘어 유출되었고, 에이전트는 사용자가 지정하지도 않은 클라이언트의 라이브 조직(live org)에서 동작한 것입니다.
모델에게 조심하라고 말하는 것으로는 이 문제를 해결할 수 없습니다. ID가 모델의 메모리에 그대로 존재하며, 모델 입장에서는 그것이 관련이 있다고 생각할 충분한 이유가 있기 때문입니다. 그래서 우리는 모델로부터 ID를 완전히 제거했습니다.
이제 모델은 실제 connectionId를 절대 보지 못합니다. 대신 별칭(alias)을 봅니다. gmail-1, salesforce-2와 같은 식입니다. 모델은 오직 이 별칭들로만 작업하며, 모든 CLI 명령 또한 별칭을 사용하므로 모델이 가공되지 않은(raw) ID에 직접 닿을 수 있는 경로는 없습니다. 이 방식이 작동하게 만드는 핵심 트릭은 동일한 별칭이 세션마다 다른 실제 ID로 해석(resolve)된다는 점입니다. 오늘의 salesforce-1은 지난주의 salesforce-1이 아닙니다. 해석은 명령이 실제로 실행되는 서버 측(server-side)에서 이루어집니다. 명령이 세션 ID를 전달하면, 우리는 해당 세션의 salesforce-1이 어떤 실제 연결에 매핑되는지 알고 있으며, 마지막 순간에 이를 교체합니다.
따라서 메모리가 오래된 salesforce-1을 새로운 세션으로 끌고 오더라도 무해합니다. 왜냐하면 해당 세션에서 그 별칭은 현재 세션이 지정하는 곳을 가리키기 때문입니다. 모델이 기억하는 대상이 두 번 연속으로 같은 의미를 갖지 않는다면, 메모리는 세션 간에 정체성(identity)을 유출할 수 없습니다.
그다음 우리는 한 단계 더 나아갔습니다. 모델이 스스로 실제 ID를 찾아다니지 않을 것이라고 단순히 믿고 싶지 않았기 때문입니다. 에이전트(Agent)는 Linux에서 제한된 사용자(restricted user)로 실행됩니다. 별칭(alias)을 실제 연결 ID(connectionIds)로 변환하는 매퍼(mapper)는 다른 사용자의 소유이며, 에이전트 사용자는 이를 읽을 수 없습니다. 에이전트는 단순히 함수를 호출할 뿐이며, 해당 함수는 조회를 수행하고 결과를 가져올 수 있는 적절한 권한을 가진 워커(worker)를 실행합니다. 따라서 모델이 별칭을 우회하여 실제 ID를 탈취하기로 결정하더라도, 이를 가능하게 하는 파일을 읽을 수 없습니다. 프롬프트(prompt) 수준이 아니라, 운영체제(OS) 수준에서 키(keys)를 가지고 있지 않기 때문입니다.
에이전트는 다이어그램의 두 번째 부분에 절대 나타나지 않습니다. 에이전트는 별칭과 세션 ID(session id)를 넘겨줄 뿐이며, 실제 ID를 알고 있는 부분은 에이전트가 넘을 수 없는 권한 경계(permission boundary) 뒤에 존재합니다.
여전히 부족한 점
이 방식이 무엇을 제공하고 무엇을 제공하지 못하는지에 대해 솔직하게 말씀드리고 싶습니다. 이 내용을 모두 읽고 나면 시스템이 완벽하게 잠겨 있다고 가정하기 쉽기 때문입니다. 하지만 그렇지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기