Skip to content

Commit 3ce43bd

Browse files
committed
feat(platform): Ollama prompt cache
1 parent 5db564b commit 3ce43bd

File tree

12 files changed

+316
-5
lines changed

12 files changed

+316
-5
lines changed

docs/bundles/ai-bundle.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,28 @@ Advanced Example with Multiple Agents
136136
vectorizer: 'ai.vectorizer.mistral_embeddings'
137137
store: 'ai.store.memory.research'
138138
139+
Cached platform
140+
---------------
141+
142+
Thanks to Symfony's Cache component, platforms can be decorated and use any cache adapter,
143+
this platform allows to reduce network calls / resource comsumption:
144+
145+
.. code-block:: yaml
146+
147+
# config/packages/ai.yaml
148+
ai:
149+
platform:
150+
openai:
151+
api_key: '%env(OPENAI_API_KEY)%'
152+
cache:
153+
platform: 'ai.platform.openai'
154+
service: 'cache.app'
155+
156+
agent:
157+
openai:
158+
platform: 'ai.platform.cache.openai'
159+
model: 'gpt-4o-mini'
160+
139161
Store Dependency Injection
140162
--------------------------
141163

docs/components/platform.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,29 @@ which can be useful to speed up the processing::
374374
echo $result->asText().PHP_EOL;
375375
}
376376

377+
Cached Platform Calls
378+
---------------------
379+
380+
Thanks to Symfony's Cache component, platform's calls can be cached to reduce network/resources calls/consumption::
381+
382+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
383+
use Symfony\AI\Platform\CachedPlatform;
384+
use Symfony\AI\Platform\Message\Message;
385+
use Symfony\AI\Platform\Message\MessageBag;
386+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
387+
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
388+
389+
$platform = PlatformFactory::create($apiKey, eventDispatcher: $dispatcher);
390+
$cachedPlatform = new CachedPlatform($platform, new TagAwareAdapter(new ArrayAdapter());
391+
392+
$firstResult = $cachedPlatform->invoke('gpt-4o-mini', new MessageBag(Message::ofUser('What is the capital of France?')));
393+
394+
echo $firstResult->getContent().\PHP_EOL;
395+
396+
$secondResult = $cachedPlatform->invoke('gpt-4o-mini', new MessageBag(Message::ofUser('What is the capital of France?')));
397+
398+
echo $secondResult->getContent().\PHP_EOL;
399+
377400
Testing Tools
378401
-------------
379402

examples/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ VOYAGE_API_KEY=
1616
REPLICATE_API_KEY=
1717

1818
# For using Ollama
19-
OLLAMA_HOST_URL=http://localhost:11434
19+
OLLAMA_HOST_URL=http://127.0.0.1:11434
2020
OLLAMA_LLM=llama3.2
2121
OLLAMA_EMBEDDINGS=nomic-embed-text
2222

examples/misc/agent-with-cache.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
14+
use Symfony\AI\Platform\CachedPlatform;
15+
use Symfony\AI\Platform\Message\Message;
16+
use Symfony\AI\Platform\Message\MessageBag;
17+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
18+
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
19+
20+
require_once dirname(__DIR__).'/bootstrap.php';
21+
22+
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), http_client());
23+
$cachedPlatform = new CachedPlatform($platform, new TagAwareAdapter(new ArrayAdapter()));
24+
25+
$agent = new Agent($cachedPlatform, 'qwen3:0.6b-q4_K_M');
26+
$messages = new MessageBag(
27+
Message::forSystem('You are a helpful assistant.'),
28+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
29+
);
30+
$result = $agent->call($messages, [
31+
'prompt_cache_key' => 'chat',
32+
]);
33+
34+
assert($result->getMetadata()->has('cached'));
35+
36+
echo $result->getContent().\PHP_EOL;
37+
38+
$secondResult = $agent->call($messages, [
39+
'prompt_cache_key' => 'chat',
40+
]);
41+
42+
assert($secondResult->getMetadata()->has('cached'));
43+
44+
echo $secondResult->getContent().\PHP_EOL;

src/ai-bundle/config/options.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@
5454
->end()
5555
->end()
5656
->end()
57+
->arrayNode('cache')
58+
->useAttributeAsKey('name')
59+
->arrayPrototype()
60+
->children()
61+
->stringNode('platform')->isRequired()->end()
62+
->stringNode('service')->isRequired()->end()
63+
->stringNode('cache_key')->end()
64+
->end()
65+
->end()
66+
->end()
5767
->arrayNode('eleven_labs')
5868
->children()
5969
->stringNode('host')->end()
@@ -130,6 +140,7 @@
130140
->defaultValue('http_client')
131141
->info('Service ID of the HTTP client to use')
132142
->end()
143+
->scalarNode('cache')->end()
133144
->end()
134145
->end()
135146
->arrayNode('cerebras')

src/ai-bundle/src/AiBundle.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory as ScalewayPlatformFactory;
5555
use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory as VertexAiPlatformFactory;
5656
use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory as VoyagePlatformFactory;
57+
use Symfony\AI\Platform\CachedPlatform;
5758
use Symfony\AI\Platform\Exception\RuntimeException;
5859
use Symfony\AI\Platform\Message\Content\File;
5960
use Symfony\AI\Platform\ModelClientInterface;
@@ -291,6 +292,25 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
291292
return;
292293
}
293294

295+
if ('cache' === $type) {
296+
foreach ($platform as $name => $config) {
297+
$definition = (new Definition(CachedPlatform::class))
298+
->setDecoratedService($config['platform'])
299+
->setArguments([
300+
new Reference('.inner'),
301+
new Reference($config['service']),
302+
$config['cache_key'],
303+
])
304+
->setLazy(true)
305+
->addTag('proxy', ['interface' => PlatformInterface::class])
306+
->addTag('ai.platform', ['name' => 'cache']);
307+
308+
$container->setDefinition('ai.platform.cache.'.$name, $definition);
309+
}
310+
311+
return;
312+
}
313+
294314
if ('eleven_labs' === $type) {
295315
$platformId = 'ai.platform.eleven_labs';
296316
$definition = (new Definition(Platform::class))

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2725,6 +2725,41 @@ public function testVectorizerModelBooleanOptionsArePreserved()
27252725
$this->assertSame('text-embedding-3-small?normalize=false&cache=true&nested%5Bbool%5D=false', $vectorizerDefinition->getArgument(1));
27262726
}
27272727

2728+
public function testCachedPlatformCanBeUsed()
2729+
{
2730+
$container = $this->buildContainer([
2731+
'ai' => [
2732+
'platform' => [
2733+
'ollama' => [
2734+
'host_url' => 'http://127.0.0.1:11434',
2735+
],
2736+
'cache' => [
2737+
'ollama' => [
2738+
'platform' => 'ai.platform.ollama',
2739+
'service' => 'cache.app',
2740+
'cache_key' => 'ollama',
2741+
],
2742+
],
2743+
],
2744+
],
2745+
]);
2746+
2747+
$this->assertTrue($container->hasDefinition('ai.platform.cache.ollama'));
2748+
2749+
$definition = $container->getDefinition('ai.platform.cache.ollama');
2750+
$this->assertCount(3, $definition->getArguments());
2751+
2752+
$this->assertInstanceOf(Reference::class, $definition->getArgument(0));
2753+
$platformArgument = $definition->getArgument(0);
2754+
$this->assertSame('.inner', (string) $platformArgument);
2755+
2756+
$this->assertInstanceOf(Reference::class, $definition->getArgument(1));
2757+
$cacheArgument = $definition->getArgument(1);
2758+
$this->assertSame('cache.app', (string) $cacheArgument);
2759+
2760+
$this->assertSame('ollama', $definition->getArgument(2));
2761+
}
2762+
27282763
private function buildContainer(array $configuration): ContainerBuilder
27292764
{
27302765
$container = new ContainerBuilder();
@@ -2763,6 +2798,13 @@ private function getFullConfig(): array
27632798
'api_version' => '2024-02-15-preview',
27642799
],
27652800
],
2801+
'cache' => [
2802+
'azure' => [
2803+
'platform' => 'ai.platform.azure',
2804+
'service' => 'cache.app',
2805+
'cache_key' => 'foo',
2806+
],
2807+
],
27662808
'eleven_labs' => [
27672809
'host' => 'https://api.elevenlabs.io/v1',
27682810
'api_key' => 'eleven_labs_key_full',

src/platform/composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,16 @@
6565
"phpstan/phpstan-symfony": "^2.0.6",
6666
"phpunit/phpunit": "^11.5",
6767
"symfony/ai-agent": "@dev",
68+
"symfony/cache": "^7.3|^8.0",
6869
"symfony/console": "^7.3|^8.0",
6970
"symfony/dotenv": "^7.3|^8.0",
7071
"symfony/finder": "^7.3|^8.0",
7172
"symfony/process": "^7.3|^8.0",
7273
"symfony/var-dumper": "^7.3|^8.0"
7374
},
75+
"suggest": {
76+
"symfony/cache": "Enable caching for platforms"
77+
},
7478
"autoload": {
7579
"psr-4": {
7680
"Symfony\\AI\\Platform\\": "src/"

src/platform/src/Bridge/Ollama/OllamaResultConverter.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
/**
2727
* @author Christopher Hertel <[email protected]>
2828
*/
29-
final readonly class OllamaResultConverter implements ResultConverterInterface
29+
final class OllamaResultConverter implements ResultConverterInterface
3030
{
3131
public function supports(Model $model): bool
3232
{
@@ -43,13 +43,14 @@ public function convert(RawResultInterface $result, array $options = []): Result
4343

4444
return \array_key_exists('embeddings', $data)
4545
? $this->doConvertEmbeddings($data)
46-
: $this->doConvertCompletion($data);
46+
: $this->doConvertCompletion($data, $options);
4747
}
4848

4949
/**
5050
* @param array<string, mixed> $data
51+
* @param array<string, mixed> $options
5152
*/
52-
public function doConvertCompletion(array $data): ResultInterface
53+
public function doConvertCompletion(array $data, array $options): ResultInterface
5354
{
5455
if (!isset($data['message'])) {
5556
throw new RuntimeException('Response does not contain message.');
@@ -69,7 +70,19 @@ public function doConvertCompletion(array $data): ResultInterface
6970
return new ToolCallResult(...$toolCalls);
7071
}
7172

72-
return new TextResult($data['message']['content']);
73+
$result = new TextResult($data['message']['content']);
74+
75+
if (\array_key_exists('prompt_cache_key', $options)) {
76+
$metadata = $result->getMetadata();
77+
78+
$metadata->add('cached', true);
79+
$metadata->add('prompt_cache_key', $options['prompt_cache_key']);
80+
$metadata->add('cached_prompt_count', $data['prompt_eval_count']);
81+
$metadata->add('cached_completion_count', $data['eval_count']);
82+
$metadata->add('cached_time', (new \DateTimeImmutable())->getTimestamp());
83+
}
84+
85+
return $result;
7386
}
7487

7588
/**
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform;
13+
14+
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
15+
use Symfony\AI\Platform\Result\DeferredResult;
16+
use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
17+
use Symfony\Contracts\Cache\CacheInterface;
18+
use Symfony\Contracts\Cache\ItemInterface;
19+
20+
/**
21+
* @author Guillaume Loulier <[email protected]>
22+
*/
23+
final class CachedPlatform implements PlatformInterface
24+
{
25+
public function __construct(
26+
private readonly PlatformInterface $platform,
27+
private readonly (CacheInterface&TagAwareAdapterInterface)|null $cache = null,
28+
private readonly ?string $cacheKey = null,
29+
) {
30+
}
31+
32+
public function invoke(string $model, array|string|object $input, array $options = []): DeferredResult
33+
{
34+
$invokeCall = fn (string $model, array|string|object $input, array $options = []): DeferredResult => $this->platform->invoke($model, $input, $options);
35+
36+
if ($this->cache instanceof CacheInterface && (\array_key_exists('prompt_cache_key', $options) && '' !== $options['prompt_cache_key'])) {
37+
$cacheKey = \sprintf('%s_%s', $this->cacheKey ?? $options['prompt_cache_key'], md5($model));
38+
39+
unset($options['prompt_cache_key']);
40+
41+
return $this->cache->get($cacheKey, static function (ItemInterface $item) use ($invokeCall, $model, $input, $options, $cacheKey): DeferredResult {
42+
$item->tag($model);
43+
44+
$result = $invokeCall($model, $input, $options);
45+
46+
$result = new DeferredResult(
47+
$result->getResultConverter(),
48+
$result->getRawResult(),
49+
$options,
50+
);
51+
52+
$result->getMetadata()->set([
53+
'cached' => true,
54+
'cache_key' => $cacheKey,
55+
'cached_at' => (new \DateTimeImmutable())->getTimestamp(),
56+
]);
57+
58+
return $result;
59+
});
60+
}
61+
62+
return $invokeCall($model, $input, $options);
63+
}
64+
65+
public function getModelCatalog(): ModelCatalogInterface
66+
{
67+
return $this->platform->getModelCatalog();
68+
}
69+
}

0 commit comments

Comments
 (0)