live loop가 생긴 뒤, 문제는 model performance에서 control behavior로 이동했다. controller는 좁은 의미에서는 정확할 수 있지만, reward function이 잘못된 우선순위를 가르치면 전체적으로는 나쁜 행동을 할 수 있다.
그래서 Optuna와 Ray/RLlib가 프로젝트에 들어왔다. 나는 tuning이 보이지 않는 상태로 남는 것을 원하지 않았다. reward weight가 바뀌면 dashboard가 trial을 보여줘야 했다. RL이 fast loop에 계속 들어가기에는 너무 무겁다면, 그 결정도 보이게 남아야 했다. 실험은 최종 parameter뿐 아니라 reasoning trail을 보존해야 했다.
이 단계는 optimization에 책임성을 부여하는 과정이었다. 질문은 단순히 어떤 configuration이 이기는가가 아니었다. search process 자체를 충분히 검토할 수 있어야 그 결과로 나온 behavior를 신뢰할 수 있었다.
Reward 문제
Orchestrator에는 서로 경쟁하는 세 가지 본능이 있었다. Task를 살려두고, waste를 줄이고, queue를 보호하는 것이다. 나는 이것들을 Agent A, Agent B, Agent C reward로 표현했다. Combined score에는 alpha, beta, gamma weight를 사용했다. 처음에는 너무 단순하게 느껴졌지만, weight 변화가 controller의 성격을 어떻게 바꾸는지 볼 수 있었기 때문에 유용했다.
@dataclass(slots=True)
class Score:
raw_rewards: dict[str, float]
alpha: float = 1.0
beta: float = 1.0
gamma: float = 1.0
@property
def total(self) -> float:
return (
self.alpha * self.raw_rewards.get("AgentA", 0.0)
+ self.beta * self.raw_rewards.get("AgentB", 0.0)
+ self.gamma * self.raw_rewards.get("AgentC", 0.0)
)
나는 이것을 완벽한 reward design으로 취급하지 않았다. 실용적인 controller surface였다. Agent A가 모든 것을 지배하면 system은 safety-heavy해진다. Agent B가 너무 매력적이면 controller는 queue health가 좋지 않은 상황에서도 efficiency를 쫓을 수 있다. Agent C가 너무 강하면 admission behavior가 지나치게 방어적으로 변할 수 있다.
보이는 search process로서의 Optuna
Optuna는 reward weight와 policy parameter를 tuning할 수 있는 방법을 줬다. 손으로 완벽한 constant를 발견한 척하지 않아도 됐다. 중요한 부분은 trial history를 export하고 dashboard에 반영하는 것이었다. Completed trial, best value, selected parameter를 언젠가 갱신을 잊어버릴 별도 notebook이 아니라 runtime의 일부로 보고 싶었다.
study = optuna.create_study(
direction="maximize",
storage=storage,
study_name=study_name,
load_if_exists=True,
)
state.optuna_history(
study_name,
history,
best_value=study.best_value if study.best_trial else None,
best_params=dict(study.best_params) if study.best_trial else {},
status="running",
)
Trial value 자체보다 workflow가 더 중요했다. Controller experiment라면 자신의 tuning state를 보여줄 수 있어야 한다. 내가 Optuna가 disabled된 dashboard screenshot을 공개한다면, 독자는 그것이 fast run이었다는 것을 알아야 한다. Optuna가 trial을 완료한 screenshot을 공개한다면, dashboard는 best parameter와 history를 보여줘야 한다.
Ray/RLlib는 유용했지만 loop에 계속 넣어두기에는 부담이 컸다
Ray/RLlib는 더 formal한 multi-agent policy path를 제공했다. Environment는 AgentA, AgentB, AgentC를 shared observation vector와 별도 action space를 가진 separate agent로 노출했다. Architecture와 잘 맞았지만, local development에서는 조심스러울 수밖에 없었다. PPO bootstrap은 iteration을 느리게 만들 수 있어서, live Kubernetes behavior를 debug할 때는 자주 꺼두었다.
self.possible_agents = ["AgentA", "AgentB", "AgentC"]
self.observation_spaces = {
"AgentA": Box(low=0.0, high=1.0, shape=(6,), dtype=float),
"AgentB": Box(low=0.0, high=1.0, shape=(6,), dtype=float),
"AgentC": Box(low=0.0, high=1.0, shape=(6,), dtype=float),
}
self.action_spaces = {
"AgentA": Discrete(POLICY_SPACES["AgentA"].action_count),
"AgentB": Discrete(POLICY_SPACES["AgentB"].action_count),
"AgentC": Discrete(POLICY_SPACES["AgentC"].action_count),
}
여기서 dashboard가 중요했던 이유
Dashboard state 없이 tuning하는 것은 어두운 방에서 knob를 돌리는 것과 같았다. Learning/reward view는 reward history, current action, agent proposal, Optuna state, Ray status가 run mode와 맞아떨어지는지 보여줬다. 이상한 흐름이 보이면 tuning 문제인지, disabled feature 문제인지, live data 문제인지 구분할 수 있었다.
이 단계는 하나의 magic policy를 주장하는 일에도 흥미를 덜 느끼게 만들었다. 이 project는 보이는 control experiment일 때 더 재미있었다. 어떤 때는 deterministic agent와 referee를 원했다. 읽기 쉬웠기 때문이다. 어떤 때는 Optuna search를 원했다. 또 어떤 때는 RLlib policy bootstrapping을 원했다. Dashboard는 지금 어느 쪽이 active인지 보여줘야 했다.
이 단계가 남긴 것
Reward tuning이 끝날 무렵 system은 live controller decision과 나란히 reward trajectory, optimization state, policy state를 노출할 수 있었다. 다음 단계는 더 어려웠다. Experimental controller를 Kubernetes 사용자들이 실제로 알아볼 수 있는 baseline, 즉 HPA와 Karpenter-like capacity behavior에 비교하는 일이었다.
reward tuning은 프로젝트를 실제 control engineering에 더 가깝게 만들었다. 모든 objective가 다른 objective와 경쟁하도록 만들었고, 그 tradeoff를 하나의 score 안에 숨기는 대신 논의 가능한 형태로 드러냈다.