Построение граф‑агента на Gemini: полный код для планирования, поиска, вычислений и самокритики
Пошаговое руководство с полным кодом для создания граф‑структурированного AI‑агента на базе Gemini, реализующего планирование, поиск, вычисления и автоматическую проверку ответа.
Обзор архитектуры
В этом руководстве показана полноценная реализация граф‑структурированного AI-агента, где Gemini (gemini-1.5-flash) используется как основной движок рассуждений. Архитектура разделяет обязанности между направленными узлами: планирование, маршрутизация, исследование (retrieval), вычисления (math), генерация ответа (write) и критика. Каждый узел изменяет общее состояние State и возвращает метку следующего узла, что делает систему модульной и удобной для тестирования.
Обёртка модели и хелпер вызова LLM
Код показывает, как обернуть Gemini в лёгкий интерфейс с системной инструкцией и функцией вызова с контролируемой температурой.
import os, json, time, ast, math, getpass
from dataclasses import dataclass, field
from typing import Dict, List, Callable, Any
import google.generativeai as genai
try:
import networkx as nx
except ImportError:
nx = Nonedef make_model(api_key: str, model_name: str = "gemini-1.5-flash"):
genai.configure(api_key=api_key)
return genai.GenerativeModel(model_name, system_instruction=(
"You are GraphAgent, a principled planner-executor. "
"Prefer structured, concise outputs; use provided tools when asked."
))
def call_llm(model, prompt: str, temperature=0.2) -> str:
r = model.generate_content(prompt, generation_config={"temperature": temperature})
return (r.text or "").strip()Безопасные вычисления и локальная база документов
В руководстве реализован безопасный вычислитель арифметики через ast и простой поиск по небольшому списку DOCS, что позволяет демонстрировать retrieval-augmented generation без внешних зависимостей.
def safe_eval_math(expr: str) -> str:
node = ast.parse(expr, mode="eval")
allowed = (ast.Expression, ast.BinOp, ast.UnaryOp, ast.Num, ast.Constant,
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod,
ast.USub, ast.UAdd, ast.FloorDiv, ast.AST)
def check(n):
if not isinstance(n, allowed): raise ValueError("Unsafe expression")
for c in ast.iter_child_nodes(n): check(c)
check(node)
return str(eval(compile(node, "<math>", "eval"), {"__builtins__": {}}, {}))
DOCS = [
"Solar panels convert sunlight to electricity; capacity factor ~20%.",
"Wind turbines harvest kinetic energy; onshore capacity factor ~35%.",
"RAG = retrieval-augmented generation joins search with prompting.",
"LangGraph enables cyclic graphs of agents; good for tool orchestration.",
]
def search_docs(q: str, k: int = 3) -> List[str]:
ql = q.lower()
scored = sorted(DOCS, key=lambda d: -sum(w in d.lower() for w in ql.split()))
return scored[:k]State и реализация узлов
Typed dataclass State хранит задачу, план, заметки scratch, доказательства evidence, результат, шаг и флаг done. Каждый узел принимает State и модель, обновляет состояние и возвращает следующую метку узла.
@dataclass
class State:
task: str
plan: str = ""
scratch: List[str] = field(default_factory=list)
evidence: List[str] = field(default_factory=list)
result: str = ""
step: int = 0
done: bool = False
def node_plan(state: State, model) -> str:
prompt = f"""Plan step-by-step to solve the user task.
Task: {state.task}
Return JSON: {{"subtasks": ["..."], "tools": {{"search": true/false, "math": true/false}}, "success_criteria": ["..."]}}"""
js = call_llm(model, prompt)
try:
plan = json.loads(js[js.find("{"): js.rfind("}")+1])
except Exception:
plan = {"subtasks": ["Research", "Synthesize"], "tools": {"search": True, "math": False}, "success_criteria": ["clear answer"]}
state.plan = json.dumps(plan, indent=2)
state.scratch.append("PLAN:\n"+state.plan)
return "route"
def node_route(state: State, model) -> str:
prompt = f"""You are a router. Decide next node.
Context scratch:\n{chr(10).join(state.scratch[-5:])}
If math needed -> 'math', if research needed -> 'research', if ready -> 'write'.
Return one token from [research, math, write]. Task: {state.task}"""
choice = call_llm(model, prompt).lower()
if "math" in choice and any(ch.isdigit() for ch in state.task):
return "math"
if "research" in choice or not state.evidence:
return "research"
return "write"
def node_research(state: State, model) -> str:
prompt = f"""Generate 3 focused search queries for:
Task: {state.task}
Return as JSON list of strings."""
qjson = call_llm(model, prompt)
try:
queries = json.loads(qjson[qjson.find("["): qjson.rfind("]")+1])[:3]
except Exception:
queries = [state.task, "background "+state.task, "pros cons "+state.task]
hits = []
for q in queries:
hits.extend(search_docs(q, k=2))
state.evidence.extend(list(dict.fromkeys(hits)))
state.scratch.append("EVIDENCE:\n- " + "\n- ".join(hits))
return "route"
def node_math(state: State, model) -> str:
prompt = "Extract a single arithmetic expression from this task:\n"+state.task
expr = call_llm(model, prompt)
expr = "".join(ch for ch in expr if ch in "0123456789+-*/().%^ ")
try:
val = safe_eval_math(expr)
state.scratch.append(f"MATH: {expr} = {val}")
except Exception as e:
state.scratch.append(f"MATH-ERROR: {expr} ({e})")
return "route"
def node_write(state: State, model) -> str:
prompt = f"""Write the final answer.
Task: {state.task}
Use the evidence and any math results below, cite inline like [1],[2].
Evidence:\n{chr(10).join(f'[{i+1}] '+e for i,e in enumerate(state.evidence))}
Notes:\n{chr(10).join(state.scratch[-5:])}
Return a concise, structured answer."""
draft = call_llm(model, prompt, temperature=0.3)
state.result = draft
state.scratch.append("DRAFT:\n"+draft)
return "critic"
def node_critic(state: State, model) -> str:
prompt = f"""Critique and improve the answer for factuality, missing steps, and clarity.
If fix needed, return improved answer. Else return 'OK'.
Answer:\n{state.result}\nCriteria:\n{state.plan}"""
crit = call_llm(model, prompt)
if crit.strip().upper() != "OK" and len(crit) > 30:
state.result = crit.strip()
state.scratch.append("REVISED")
state.done = True
return "end"
NODES: Dict[str, Callable[[State, Any], str]] = {
"plan": node_plan, "route": node_route, "research": node_research,
"math": node_math, "write": node_write, "critic": node_critic
}
def run_graph(task: str, api_key: str) -> State:
model = make_model(api_key)
state = State(task=task)
cur = "plan"
max_steps = 12
while not state.done and state.step < max_steps:
state.step += 1
nxt = NODES[cur](state, model)
if nxt == "end": break
cur = nxt
return state
def ascii_graph():
return """
START -> plan -> route -> (research <-> route) & (math <-> route) -> write -> critic -> END
"""Запуск графа и точка входа
В конце скрипта реализована точка входа, которая безопасно читает GEMINI_API_KEY, запрашивает задачу у пользователя, запускает run_graph и печатает ASCII-диаграмму, финальный результат, доказательства и последние заметки scratch.
if __name__ == "__main__":
key = os.getenv("GEMINI_API_KEY") or getpass.getpass(" Enter GEMINI_API_KEY: ")
task = input(" Enter your task: ").strip() or "Compare solar vs wind for reliability; compute 5*7."
t0 = time.time()
state = run_graph(task, key)
dt = time.time() - t0
print("\n=== GRAPH ===", ascii_graph())
print(f"\n Result in {dt:.2f}s:\n{state.result}\n")
print("---- Evidence ----")
print("\n".join(state.evidence))
print("\n---- Scratch (last 5) ----")
print("\n".join(state.scratch[-5:]))Преимущества подхода и дальнейшие шаги
Данная схема демонстрирует, как сочетать детерминированный контроль потока с вероятностными ответами LLM. Планировщик добивается разбиения задач на подзадачи, маршрутизатор переключает между вычислениями и поиском, а критик обеспечивает итеративное улучшение точности и ясности. Архитектура легко расширяема: можно добавить собственные тулкиты, долговременную память, параллельное выполнение узлов и более мощные бекенды для поиска.
Switch Language
Read this article in English