Dal jsem DeepSeekovi oči. Jak jsme to udělal.
Můj asistent je geniální, ale žije v minulosti
Když jsem si poprvé hrál s DeepSeekem, byl jsem nadšený. Uvažuje jako člověk, píše kód, vysvětluje složité věci líp než většina lidí, které znám. Ale pak jsem se ho zeptal na něco aktuálního - třeba na novou verzi frameworku nebo cenu nového telefonu - a on s naprostou jistotou odpověděl něco, co bylo rok staré. Nebo si to prostě vymyslel.
Není to jeho chyba. DeepSeek je model, ne prohlížeč. Jeho API je čistě generativní - dostane text a vrací text. Všechno, co se naučil v době tréninku a od té doby se pro něj svět zastavil. Když se zeptáš na cokoliv aktuálního, má dvě možnosti: odhadnout to, nebo si to vymyslet. Ani jedna není dobrá.
A tady je ten vtip: DeepSeek API sice umí function calling, ale žádné vyhledávání ti nedodá. Neexistuje žádný web_search parametr, který bys zapnul. Vyhledávání si musíš napsat sám a připojit ho jako nástroj.
Tak jsem mu dal oči. Dvě funkce: web_search, kterou si zavolá, když potřebuje aktuální informace, a fetch_url, kterou si přečte konkrétní stránku. Od té chvíle to není model, který si vymýšlí - je to model, který ví, že si to má najít. Tady je přesný postup.
Co budeš potřebovat
- API klíč pro DeepSeek (
api.deepseek.com) - API klíč pro Brave Search API (
api.search.brave.com) - má free tier, stačí se zaregistrovat na brave.com/search/api - backend, který umí curl/HTTP requesty (tady PHP, princip je ale univerzální)
Krok 0: Klíče a zapojení do appky
Oba klíče drž mimo repo - v .env, který si appka načte a předá do DI kontejneru:
DEEPSEEK_API_KEY=sk-...
BRAVE_SEARCH_API_KEY=...
V Nette to vypadá jako parametr v bootstrapu:
$configurator->addDynamicParameters([
'deepseekApiKey' => $_ENV['DEEPSEEK_API_KEY'] ?? '',
'braveSearchApiKey' => $_ENV['BRAVE_SEARCH_API_KEY'] ?? '',
]);
a service definice v services.neon, kam se parametry předají konstruktorem třídy, co se stará o volání DeepSeeka:
services:
- App\Services\Ai\AssistantChat(%deepseekApiKey%, %braveSearchApiKey%)
Princip je stejný v jakémkoli frameworku - API klíč nikdy natvrdo v kódu, vždy přes env proměnnou a DI/config vrstvu.
web_search nástroj vrátí jen chybovou hlášku „není nakonfigurovaný". Takže tenhle krok je ve skutečnosti první, ne poslední.Krok 1: Definice nástrojů pro model
DeepSeek používá stejný formát tool definic jako OpenAI. Každý nástroj má jméno, popis (model podle něj pozná, KDY ho použít) a JSON schema parametrů:
private const TOOLS = [
[
'type' => 'function',
'function' => [
'name' => 'web_search',
'description' => 'Vyhledá aktuální informace na internetu (fakta, čísla, novinky). '
. 'Použij, když si nejsi jistý přesností faktu.',
'parameters' => [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'description' => 'Vyhledávací dotaz'],
],
'required' => ['query'],
'additionalProperties' => false,
],
'strict' => true,
],
],
[
'type' => 'function',
'function' => [
'name' => 'fetch_url',
'description' => 'Stáhne a přečte textový obsah konkrétní webové stránky.',
'parameters' => [
'type' => 'object',
'properties' => [
'url' => ['type' => 'string', 'description' => 'Plná URL stránky (https://…)'],
],
'required' => ['url'],
'additionalProperties' => false,
],
'strict' => true,
],
],
];
Popis (description) je nejdůležitější řádek celé definice - je to jediná informace, podle které se model rozhoduje, jestli a kdy nástroj zavolá. Piš ho konkrétně, ideálně i s „POVINNĚ použij, když…".
Proč to celé děláš: před a po
Bez nástrojů model odpoví takhle:
Uživatel: Kolik stojí nejnovější iPhone?
Asistent: Podle mých informací stojí nejnovější model iPhone přibližně 25 000 Kč. Ceny se mohou lišit podle prodejce.
(Ve skutečnosti: rok stará data, která si model pamatuje z tréninku.)
S nástroji:
Uživatel: Kolik stojí nejnovější iPhone?
Asistent: (zavolá
web_search, dostane výsledky, případně si přečte konkrétní stránku)Asistent: Nejnovější iPhone 17 Pro stojí v České republice od 29 990 Kč. Ceny podle oficiálního Apple Store: apple.com/cz.
Ten rozdíl je celý smysl tohohle článku.
Krok 2: Request na DeepSeek s nástroji
Do payloadu přidáš tools a necháš stream klidně na false pro první verzi:
$payload = [
'model' => 'deepseek-v4-flash',
'messages' => $conversation,
'max_tokens' => 12000,
'temperature' => 0.7,
'tools' => self::TOOLS,
];
$ch = curl_init('https://api.deepseek.com/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$response = curl_exec($ch);
$message = json_decode($response, true)['choices'][0]['message'];
Odpověď má buď content (text), nebo tool_calls (pole požadavků na nástroje). Nikdy obojí naráz jako finální odpověď - pokud přijde tool_calls, text ještě nepovažuj za hotový.
Krok 3: Smyčka - zavolej nástroj, vrať výsledek, opakuj
Tohle je jádro celého mechanismu. Model může chtít nástroj vícekrát po sobě (např. vyhledat, pak načíst konkrétní stránku z výsledků), takže to musí být cyklus s pojistkou proti nekonečné smyčce:
for ($round = 0; $round < 20; $round++) {
$message = $this->callDeepSeek($conversation);
$toolCalls = $message['tool_calls'] ?? null;
if (!$toolCalls) {
return $message['content']; // hotovo, model už jen odpověděl textem
}
$conversation[] = $message; // assistant zpráva s tool_calls
foreach ($toolCalls as $call) {
$conversation[] = [
'role' => 'tool',
'tool_call_id' => $call['id'],
'content' => $this->runTool($call), // spustí web_search/fetch_url
];
}
// pokračuj v cyklu — model dostane výsledky a rozhodne se dál
}
Strop 20 kol je pojistka - bez ní by se model teoreticky mohl zaseknout ve vyhledávání dokola.
Krok 4: Samotné volání Brave API
private function braveSearch(string $query): string
{
$url = 'https://api.search.brave.com/res/v1/web/search?'
. http_build_query(['q' => $query, 'count' => 10]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Subscription-Token: ' . $this->braveApiKey,
],
]);
$response = curl_exec($ch);
curl_close($ch);
$results = json_decode($response, true)['web']['results'] ?? [];
$lines = [];
foreach (array_slice($results, 0, 10) as $r) {
$lines[] = ($r['title'] ?? '') . "\n" . ($r['url'] ?? '') . "\n" . strip_tags($r['description'] ?? '');
}
return implode("\n\n", $lines); // tenhle text jde zpátky modelu jako obsah tool zprávy
}
Výsledek nástroje se modelu vrací jako obyčejný text v poli content - žádná speciální struktura. Model si z něj sám vytáhne, co potřebuje.
Krok 5: fetch_url a bezpečnost
Když necháš model, ať sám zvolí URL k načtení, musíš ošetřit SSRF - jinak ti model (nebo někdo přes model) může nechat backend stahovat interní adresy (localhost, 169.254.x.x, privátní rozsahy):
private function isSafeToFetch(string $url): bool
{
if (!preg_match('~^https?://~i', $url)) {
return false;
}
$host = parse_url($url, PHP_URL_HOST);
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
FILTER_FLAG_NO_PRIV_RANGE a FILTER_FLAG_NO_RES_RANGE odfiltrují privátní a rezervované IP rozsahy. Kontroluj to VŽDY před samotným curl requestem na fetch_url.
Tohle je chvíle, kdy ti model může ublížit - ne ze zlosti, ale protože nemá tušení, co je „uvnitř" a co je veřejný internet. Bezpečnostní kontrolu ber jako součást nástroje, ne jako volitelné vylepšení.
Krok 6: Streamovaná verze Volitelné, ale doporučené
Pro chat UI chceš vidět text psát se postupně, ne čekat na celou odpověď. Rozdíl je v stream => true a parsování SSE (server-sent events) místo jednoho JSON response. Klíčová věc: pokud model zrovna volá nástroj, žádný text se nestreamuje (delta obsahuje jen tool_calls), takže spojení může dlouho „mlčet". Řešení je curl heartbeat - pošli si pravidelný impuls přes XFERINFOFUNCTION, ať proxy/webserver spojení nezabije jako neaktivní:
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => function (...$args) use (&$last, $onHeartbeat) {
if (time() - $last >= 15) {
$last = time();
$onHeartbeat(); // např. pošle prázdný SSE komentář klientovi
}
return 0;
},
Krok 7: Strukturované zdroje v odpovědi
Ať se odkazy, na které se model odkazuje, nezamotají do textu, dej modelu instrukci v system promptu, ať je na konec odpovědi napíše ve fixním formátu:
Pokud cituješ webové zdroje, na konec odpovědi vždy přidej sekci:
ZDROJE:
- [Název zdroje](https://...)
- [Další zdroj](https://...)
Na backendu ji pak vytáhneš regexem a z textu smažeš, ať zůstane čistý.
Co si z toho odnést
Model není chytrý proto, že ví všechno. Je chytrý proto, že ví, kdy si to má najít.
Asistent má teď přístup k aktuálním informacím, umí si sám říct o vyhledání a pokračovat v odpovědi bez ručního zásahu. A celé to stojí na dvou API klíčích, jedné smyčce a pár řádcích kódu - žádná magie, jen function calling dotažený do konce.
Příště: jak z DeepSeeka dostat přímou odpověď
Řeč bude o tom, jak zneškodnit jeho bezpečnostní mechanismy a přimět ho odpovídat i na přísně zakázaná témata, jako je detailní popis sebevražd či podobně citlivý obsah.